From 4c890ebe274f2eb875bc76f86291de86443d4a52 Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Thu, 29 Oct 2026 13:31:17 +0000 Subject: [PATCH 1/8] fix(ui): reconcile session and delegation activity --- .../primitives/DesktopSessionList.svelte | 3 + .../primitives/DesktopSessionList.test.ts | 5 + .../session/SessionActivityBar.svelte | 14 +- .../components/session/SessionHeader.svelte | 12 +- .../components/shell/RecentSessionRail.svelte | 2 +- .../shell/RecentSessionRail.test.ts | 5 + src/lib/domain/session-snapshot.test.ts | 22 +- src/lib/domain/session-snapshot.ts | 85 +-- src/lib/domain/session-updates.test.ts | 44 +- src/lib/domain/session-updates.ts | 175 ++++-- src/lib/domain/sessions.test.ts | 14 +- src/lib/domain/sessions.ts | 17 +- src/lib/domain/types.ts | 10 +- src/lib/querymt/generated/types.ts | 17 +- src/lib/querymt/querymt-extensions.ts | 7 + src/lib/stores/agents.svelte.test.ts | 518 +++++++++++++++++- src/lib/stores/agents.svelte.ts | 324 +++++++++-- .../[agentId]/[sessionId]/+page.svelte | 7 +- 18 files changed, 1066 insertions(+), 215 deletions(-) diff --git a/src/lib/components/primitives/DesktopSessionList.svelte b/src/lib/components/primitives/DesktopSessionList.svelte index ad6eee05..698716f4 100644 --- a/src/lib/components/primitives/DesktopSessionList.svelte +++ b/src/lib/components/primitives/DesktopSessionList.svelte @@ -379,6 +379,8 @@ function getStatusLabel(status: SessionStatus): string { switch (status) { + case 'unknown': + return 'Status unavailable'; case 'thinking': return 'Thinking'; case 'waiting': @@ -399,6 +401,7 @@ case 'cancelling': return 'running'; case 'waiting': + case 'unknown': return 'warning'; case 'completed': return 'success'; diff --git a/src/lib/components/primitives/DesktopSessionList.test.ts b/src/lib/components/primitives/DesktopSessionList.test.ts index 8e1d18ee..b8091e06 100644 --- a/src/lib/components/primitives/DesktopSessionList.test.ts +++ b/src/lib/components/primitives/DesktopSessionList.test.ts @@ -172,6 +172,11 @@ describe('DesktopSessionList recent sessions', () => { }); describe('DesktopSessionList', () => { + it('does not label unknown execution as Idle', () => { + render(DesktopSessionList, { sessions: [{ ...sessions[0], status: 'unknown' }] }); + expect(screen.getByLabelText('Status: Status unavailable')).toBeInTheDocument(); + expect(screen.queryByLabelText('Status: Idle')).not.toBeInTheDocument(); + }); it('delays loading chrome while keeping the pending state distinct from an empty catalog', async () => { vi.useFakeTimers(); const { container } = render(DesktopSessionList, { loading: true }); diff --git a/src/lib/components/session/SessionActivityBar.svelte b/src/lib/components/session/SessionActivityBar.svelte index f12c4286..aaa1718d 100644 --- a/src/lib/components/session/SessionActivityBar.svelte +++ b/src/lib/components/session/SessionActivityBar.svelte @@ -17,8 +17,8 @@ session.runState === 'streaming' || session.runState === 'tool-running' ); - const isBusy = $derived(session.undo.pendingOperation !== null || forkPending || agentBusy); - const isVisible = $derived(isBusy || session.runState === 'failed' || session.runState === 'waiting-input'); + const isBusy = $derived(session.undo.pendingOperation !== null || forkPending || agentBusy || !!session.dependencyLabel); + const isVisible = $derived(isBusy || session.runState === 'failed' || session.runState === 'waiting-input' || session.runState === 'unknown'); const activeTool = $derived(session.toolCalls.find((tool) => tool.id === session.activeToolCallId) ?? null); const activeToolPresentation = $derived(activeTool ? getSessionToolPresentation(activeTool) : null); @@ -28,12 +28,16 @@ if (session.undo.pendingOperation === 'redo') return 'Restoring workspace changes…'; if (session.runState === 'failed') return session.lastError ? `Failed: ${session.lastError}` : 'Session failed'; if (session.runState === 'waiting-input') return 'Input needed'; + if (session.runState === 'unknown') return session.activityLabel ?? 'Execution status unavailable'; + if (session.dependencyLabel && !agentBusy) return `Own turn idle - ${session.dependencyLabel}`; + if (session.activityLabel?.startsWith('Cancelling') || session.activityLabel?.startsWith('Finishing')) return session.activityLabel; if (session.runState === 'tool-running' && activeToolPresentation) { - return `${activeToolPresentation.label}${activeToolPresentation.preview ? ` · ${activeToolPresentation.preview}` : ''}`; + const count = session.toolCalls.filter((tool) => (tool.status === 'pending' || tool.status === 'in_progress') && (!session.runId || !tool.runId || tool.runId === session.runId)).length; + return `${activeToolPresentation.label}${activeToolPresentation.preview ? ` · ${activeToolPresentation.preview}` : ''}${count > 1 ? ` (+${count - 1} active)` : ''}`; } if (session.runState === 'tool-running') return session.activityLabel ?? 'Running tool…'; if (session.runState === 'streaming') return 'Responding…'; - if (session.runState === 'thinking') return session.activityLabel ?? 'Thinking…'; + if (session.runState === 'thinking') return `${session.activityLabel ?? 'Thinking...'}${session.dependencyLabel ? ` - ${session.dependencyLabel}` : ''}`; if (session.runState === 'submitting') return 'Sending prompt…'; return ''; }); @@ -59,7 +63,7 @@ Review the error, update the prompt, or refresh. {:else if session.runState === 'waiting-input'} Respond below to continue. - {:else if !session.undo.pendingOperation && !forkPending} + {:else if agentBusy && !session.undo.pendingOperation && !forkPending} Double Esc to cancel {/if} diff --git a/src/lib/components/session/SessionHeader.svelte b/src/lib/components/session/SessionHeader.svelte index 19eef4af..a794c699 100644 --- a/src/lib/components/session/SessionHeader.svelte +++ b/src/lib/components/session/SessionHeader.svelte @@ -69,17 +69,21 @@ if (forkPending) return { label: 'Creating fork', tone: 'running', busy: true }; if (session.undo.pendingOperation === 'undo') return { label: 'Undoing', tone: 'running', busy: true }; if (session.undo.pendingOperation === 'redo') return { label: 'Restoring', tone: 'running', busy: true }; + if (session.runState === 'unknown' || (!session.sessionId && summaryStatus === 'unknown')) return { label: 'Status unavailable', tone: 'warning', busy: false }; + if (session.dependencyLabel && !['submitting', 'thinking', 'streaming', 'tool-running'].includes(session.runState)) { + return { label: `Own turn idle - ${session.dependencyLabel}`, tone: 'running', busy: true }; + } if (session.runState === 'failed') return { label: 'Failed', tone: 'danger', busy: false }; - if (session.runState === 'waiting-input' || summaryStatus === 'waiting') { + if (session.runState === 'waiting-input' || (!session.sessionId && summaryStatus === 'waiting')) { return { label: 'Input needed', tone: 'warning', busy: false }; } if ( ['submitting', 'thinking', 'streaming', 'tool-running'].includes(session.runState) || - ['thinking', 'cancelling'].includes(summaryStatus) + (!session.sessionId && ['thinking', 'cancelling'].includes(summaryStatus)) ) { - return { label: summaryStatus === 'cancelling' ? 'Cancelling' : 'Working', tone: 'running', busy: true }; + return { label: session.activityLabel?.startsWith('Cancelling') ? 'Cancelling' : session.activityLabel?.startsWith('Finishing') ? 'Finishing' : session.activityLabel?.startsWith('Waiting for delegates') ? 'Waiting for delegates' : 'Working', tone: 'running', busy: true }; } - if (session.runState === 'completed' || summaryStatus === 'completed') { + if (session.runState === 'completed' || (!session.sessionId && summaryStatus === 'completed')) { return { label: 'Turn complete', tone: 'muted', busy: false }; } return { label: 'Ready', tone: 'muted', busy: false }; diff --git a/src/lib/components/shell/RecentSessionRail.svelte b/src/lib/components/shell/RecentSessionRail.svelte index ff1e7c22..8f0e63c3 100644 --- a/src/lib/components/shell/RecentSessionRail.svelte +++ b/src/lib/components/shell/RecentSessionRail.svelte @@ -279,7 +279,7 @@ } function getStatusLabel(status: SessionStatus, item: SessionRailItem): string { - const activity = status === 'thinking' ? 'Active' : status === 'waiting' ? 'Waiting' : status === 'cancelling' ? 'Cancelling' : item.requiresAttention ? null : 'Recent'; + const activity = status === 'unknown' ? 'Status unavailable' : status === 'thinking' ? 'Active' : status === 'waiting' ? 'Waiting' : status === 'cancelling' ? 'Cancelling' : item.requiresAttention ? null : 'Recent'; if (item.requiresAttention) return activity ? `${activity}, action required` : 'Action required'; return activity ?? 'Recent'; } diff --git a/src/lib/components/shell/RecentSessionRail.test.ts b/src/lib/components/shell/RecentSessionRail.test.ts index d1ec954e..8630a291 100644 --- a/src/lib/components/shell/RecentSessionRail.test.ts +++ b/src/lib/components/shell/RecentSessionRail.test.ts @@ -140,6 +140,11 @@ describe('RecentSessionRail pinned sessions', () => { }); describe('RecentSessionRail navigation', () => { + it('keeps unknown execution explicit in existing rail status labels', () => { + render(RecentSessionRail, { current: 'Sessions', sessions: [{ ...activeSession, status: 'unknown' }] }); + expect(screen.getByRole('link', { name: /^Waiting session, Status unavailable/ })).toBeInTheDocument(); + expect(screen.getByText('work · Status unavailable')).toBeInTheDocument(); + }); it('defaults to a labeled sidebar with grouped navigation and readable sessions', () => { render(RecentSessionRail, { current: 'Sessions', sessions: [activeSession] }); diff --git a/src/lib/domain/session-snapshot.test.ts b/src/lib/domain/session-snapshot.test.ts index 08d4f503..6c0e9855 100644 --- a/src/lib/domain/session-snapshot.test.ts +++ b/src/lib/domain/session-snapshot.test.ts @@ -825,7 +825,7 @@ describe('activeSessionFromLoadResponse', () => { }); }); - it('marks orphaned historical tool starts as completed after later terminal session events in snapshots', () => { + it('does not infer tool completion from stored assistant content or a model request ending', () => { const session = activeSessionFromLoadResponse('session-1', { _meta: { 'querymt/sessionLoadSnapshot.v1': { @@ -859,11 +859,11 @@ describe('activeSessionFromLoadResponse', () => { expect(session.toolCalls).toHaveLength(1); expect(session.toolCalls[0]).toMatchObject({ id: 't-orphan', - status: 'completed', + status: 'in_progress', arguments: '{"path":"README.md"}' }); - expect(session.runState).toBe('completed'); - expect(session.activeToolCallId).toBeNull(); + expect(session.runState).toBe('tool-running'); + expect(session.activeToolCallId).toBe('t-orphan'); }); it.each([ @@ -898,7 +898,7 @@ describe('activeSessionFromLoadResponse', () => { expect(normalizeHistoricalSession(session, { loadCompleted: true }).runState).toBe(runState); }); - it('finalizes orphaned tools without overwriting explicit lifecycle state', () => { + it('marks interrupted tools failed only when terminal lifecycle is known', () => { const session: ActiveSessionViewModel = { sessionId: 'session-lifecycle', transcript: [], @@ -943,10 +943,10 @@ describe('activeSessionFromLoadResponse', () => { expect(normalized.activityLabel).toBe('Turn failed.'); expect(normalized.activeToolCallId).toBeNull(); expect(normalized.lastError).toBe('tool failed'); - expect(normalized.toolCalls[0]).toMatchObject({ id: 't-orphan', status: 'completed' }); + expect(normalized.toolCalls[0]).toMatchObject({ id: 't-orphan', status: 'failed' }); }); - it('uses successful session/load completion as the terminal state for replayed history', () => { + it('does not use history load completion to finish an active tool', () => { const session: ActiveSessionViewModel = { sessionId: 'session-1', transcript: [], @@ -986,12 +986,12 @@ describe('activeSessionFromLoadResponse', () => { const normalized = normalizeHistoricalSession(session, { loadCompleted: true }); - expect(normalized.runState).toBe('completed'); - expect(normalized.activityLabel).toBe('Loaded from session history.'); - expect(normalized.activeToolCallId).toBeNull(); + expect(normalized.runState).toBe('tool-running'); + expect(normalized.activityLabel).toBe('Running tool: Run shell'); + expect(normalized.activeToolCallId).toBe('t-orphan'); expect(normalized.toolCalls[0]).toMatchObject({ id: 't-orphan', - status: 'completed', + status: 'in_progress', arguments: '{"command":"echo hi"}' }); }); diff --git a/src/lib/domain/session-snapshot.ts b/src/lib/domain/session-snapshot.ts index 2384eb7f..141045d9 100644 --- a/src/lib/domain/session-snapshot.ts +++ b/src/lib/domain/session-snapshot.ts @@ -1,7 +1,7 @@ import { applyDelegationChildSession, createEmptyActiveSession, normalizeContentBlocks, stringifyOptional, summarizeContentBlocks } from '$lib/domain/session-updates'; import type { ActiveSessionViewModel, PromptAttachment, SessionContentBlock, SessionToolCallItem, SessionTranscriptItem } from '$lib/domain/types'; import { appendHistoricalUsage, readAccountingSnapshot } from '$lib/domain/session-usage'; -import type { DelegationUpdateNotification } from '$lib/querymt/generated/types'; +import type { DelegationUpdateNotification, SessionRuntimeStatus } from '$lib/querymt/generated/types'; type SnapshotEvent = { seq?: number; @@ -38,6 +38,7 @@ type IndexedStructuredPrompt = { type SessionLoadSnapshot = { /** Authoritative reducer-backed accounting at the snapshot cursor. */ accounting?: unknown; + runtimeStatus?: SessionRuntimeStatus; delegationUpdates?: DelegationUpdateNotification[]; audit?: { events?: SnapshotEvent[]; @@ -72,7 +73,6 @@ export interface SnapshotInputState { createdAt?: number; } -const TOOL_TERMINAL_EVENT_TYPES = new Set(['assistant_message_stored', 'llm_request_end']); export function activeSessionFromLoadResponse(sessionId: string, response: unknown): ActiveSessionViewModel { const session = createEmptyActiveSession(); @@ -131,6 +131,7 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno } if (kind === 'run_started') { + session.runId = readString(data.run_id) ?? null; session.runState = 'thinking'; session.runStateFromLifecycle = true; session.activityLabel = 'Agent is working…'; @@ -139,6 +140,10 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno if (kind === 'run_completed') { const outcome = readString(data.outcome); + const runId = readString(data.run_id) ?? null; + if (session.runId && runId && session.runId !== runId) continue; + session.runId = runId ?? session.runId; + session.lastRunOutcome = { runId: session.runId ?? null, outcome: outcome ?? 'completed' }; session.runState = outcome === 'failed' ? 'failed' : 'completed'; session.runStateFromLifecycle = true; session.activityLabel = outcome === 'cancelled' @@ -252,6 +257,7 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno mergeHistoricalToolCall(toolCallsById, toolCallId, { title: readString(data.tool_name), status: 'in_progress', + runId: session.runId, kind: readString(data.tool_name) ?? null, messageId, arguments: stringifyOptional(data.arguments ?? data.input), @@ -299,10 +305,14 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno restoreUnmatchedPrompts(session, snapshot, structuredPrompts, restoredStructuredPrompts, toolCallsById); - session.toolCalls = finalizeHistoricalToolCalls(Array.from(toolCallsById.values()), snapshot.audit?.events ?? []); + session.toolCalls = Array.from(toolCallsById.values()); return (snapshot.delegationUpdates ?? []).reduce(applyDelegationChildSession, normalizeHistoricalSession(session)); } +export function getSnapshotRuntimeStatus(response: unknown): SessionRuntimeStatus | null { + return readSnapshot(response)?.runtimeStatus ?? null; +} + export function getSnapshotInputStates(response: unknown): SnapshotInputState[] { const states = new Map(); for (const event of readSnapshot(response)?.audit?.events ?? []) { @@ -393,42 +403,29 @@ export function getSnapshotProviderChange(response: unknown): SnapshotProviderCh export function normalizeHistoricalSession( session: ActiveSessionViewModel, - options: { loadCompleted?: boolean } = {} + _options: { loadCompleted?: boolean } = {} ): ActiveSessionViewModel { - if (session.runStateFromLifecycle && !options.loadCompleted) return session; - - const hasActiveTool = session.toolCalls.some((tool) => tool.status === 'in_progress' || tool.status === 'pending'); - if (hasActiveTool && !options.loadCompleted) { - const activeTool = session.toolCalls.find((tool) => tool.status === 'in_progress' || tool.status === 'pending') ?? null; - session.runState = 'tool-running'; - session.activeToolCallId = activeTool?.id ?? null; - session.activityLabel = activeTool ? `Running tool: ${activeTool.title}` : 'Running tool…'; - session.lastError = null; - return session; - } - - if (options.loadCompleted) { - session.toolCalls = session.toolCalls.map((tool) => - tool.status === 'in_progress' || tool.status === 'pending' - ? { ...tool, status: tool.isError ? 'failed' : 'completed' } - : tool - ); + // Loading history is not evidence that execution or a pending tool ended. + if (session.runStateFromLifecycle && (session.runState === 'completed' || session.runState === 'failed')) { session.activeToolCallId = null; - if (!session.runStateFromLifecycle) { - session.runState = 'completed'; - session.activityLabel = 'Loaded from session history.'; - session.lastError = null; - } + session.toolCalls = session.toolCalls.map((tool) => + (tool.status === 'pending' || tool.status === 'in_progress') && (!tool.runId || tool.runId === session.runId) + ? { ...tool, status: 'failed', isError: true, result: tool.result ?? 'Run ended before a tool result was recorded.' } + : tool); return session; } - - if (session.transcript.some((item) => item.kind === 'agent_message_chunk' || item.kind === 'agent_thought_chunk')) { + const activeTool = session.toolCalls.find((tool) => tool.status === 'in_progress' || tool.status === 'pending'); + if (activeTool) { + session.runState = 'tool-running'; + session.activeToolCallId = activeTool.id; + session.activityLabel = `Running tool: ${activeTool.title}`; + session.lastError = null; + } else if (!session.runStateFromLifecycle && session.transcript.some((item) => item.kind !== 'user_message_chunk')) { session.runState = 'completed'; session.activeToolCallId = null; session.activityLabel = 'Loaded from session history.'; session.lastError = null; } - return session; } @@ -649,35 +646,7 @@ function mergeHistoricalToolCall( }); } -function finalizeHistoricalToolCalls(tools: SessionToolCallItem[], events: SnapshotEvent[]): SessionToolCallItem[] { - const terminalEventIndexes = events - .filter((event) => TOOL_TERMINAL_EVENT_TYPES.has(event.kind?.type ?? '') && typeof event.seq === 'number') - .map((event) => event.seq as number); - - return tools.map((tool) => { - if (tool.status !== 'in_progress' && tool.status !== 'pending') { - return tool; - } - - if (tool.result) { - return { - ...tool, - status: tool.isError ? 'failed' : 'completed' - }; - } - const toolEventIndex = tool.eventIndex ?? Number.MAX_SAFE_INTEGER; - const hasLaterTerminalEvent = terminalEventIndexes.some((seq) => seq > toolEventIndex); - if (!hasLaterTerminalEvent) { - return tool; - } - - return { - ...tool, - status: tool.isError ? 'failed' : 'completed' - }; - }); -} function mergeToolStatus( current: SessionToolCallItem['status'] | undefined, diff --git a/src/lib/domain/session-updates.test.ts b/src/lib/domain/session-updates.test.ts index 9b2d926d..1b2413b1 100644 --- a/src/lib/domain/session-updates.test.ts +++ b/src/lib/domain/session-updates.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import type { SessionNotification } from '@agentclientprotocol/sdk'; import type { AccountingSnapshot } from '$lib/querymt/generated/types'; +import { activeSessionFromLoadResponse } from './session-snapshot'; +import { SessionRuntimePhase, DelegationUpdateState } from '$lib/querymt/generated/types'; import { applyDelegationChildSession, applySessionNotification, @@ -9,7 +11,8 @@ import { createEmptyActiveSession, endSessionWork, getNextConversationEventIndex, - mergeMissingSessionUsage + mergeMissingSessionUsage, + projectSessionRuntime } from './session-updates'; function notification(update: SessionNotification['update']): SessionNotification { @@ -34,6 +37,20 @@ const incompleteAccounting = accounting({ }); describe('conversation event ordering', () => { + it('F2: preserves run outcome and dependency evidence across harmless notification cloning', () => { + const loaded = activeSessionFromLoadResponse('session-1', { _meta: { 'querymt/sessionLoadSnapshot.v1': { + audit: { events: [{ seq: 1, kind: { type: 'run_started', data: { run_id: 'run-a' } } }, + { seq: 2, kind: { type: 'run_completed', data: { run_id: 'run-a', outcome: 'cancelled' } } }] } + } } }); + loaded.delegations = [{ version: 1, sessionId: 'session-1', delegationId: 'd1', state: DelegationUpdateState.Forked, + targetAgentId: 'coder', objective: '', requestedAt: 1, updatedAt: 1, activity: 'live', stage: 'verifying' }]; + loaded.dependencyLabel = 'Verifying delegate result'; + const next = applySessionNotification(loaded, notification({ sessionUpdate: 'usage_update', used: 1, size: 100 })); + projectSessionRuntime(next, { phase: SessionRuntimePhase.Idle, steerable: false, pending_steering_count: 0, queued_input_count: 0 }); + expect(next).toMatchObject({ runId: 'run-a', lastRunOutcome: { runId: 'run-a', outcome: 'cancelled' }, + activityLabel: 'Turn cancelled.', dependencyLabel: 'Verifying delegate result', delegations: loaded.delegations }); + expect(loaded.lastRunOutcome?.outcome).toBe('cancelled'); + }); it('continues after sparse persisted indexes instead of using the debug event count', () => { const session = createEmptyActiveSession(); session.transcript = [ @@ -334,6 +351,31 @@ describe('mergeMissingSessionUsage', () => { }); describe('applySessionNotification tool calls', () => { + it.each(['completed', 'failed'] as const)('keeps a parallel shell active when a quick tool is %s in live and replay reducers', (status) => { + const updates = [ + notification({ sessionUpdate: 'tool_call', toolCallId: 'shell', title: 'Long shell', status: 'in_progress' }), + notification({ sessionUpdate: 'tool_call', toolCallId: 'read', title: 'Quick read', status: 'in_progress' }), + notification({ sessionUpdate: 'tool_call_update', toolCallId: 'read', status }) + ]; + const live = updates.reduce((session, update) => applySessionNotification(session, update), createEmptyActiveSession()); + const replay = appendSessionReplay(createEmptyActiveSession(), updates); + for (const session of [live, replay]) { + expect(session.toolCalls.find((tool) => tool.id === 'read')?.status).toBe(status); + expect(session.toolCalls.find((tool) => tool.id === 'shell')?.status).toBe('in_progress'); + expect(session.runState).toBe('tool-running'); + expect(session.activeToolCallId).toBe('shell'); + expect(session.lastError).toBeNull(); + } + }); + + it('does not treat the last tool failure as a terminal run failure', () => { + const session = applySessionNotification(createEmptyActiveSession(), notification({ + sessionUpdate: 'tool_call_update', toolCallId: 'read', status: 'failed' + })); + expect(session.toolCalls[0].status).toBe('failed'); + expect(session.runState).toBe('streaming'); + expect(session.lastError).toBeNull(); + }); it('merges repeated tool starts by tool call ID', () => { const start = notification({ sessionUpdate: 'tool_call', diff --git a/src/lib/domain/session-updates.ts b/src/lib/domain/session-updates.ts index 5e2ed5fb..ae68974a 100644 --- a/src/lib/domain/session-updates.ts +++ b/src/lib/domain/session-updates.ts @@ -1,4 +1,5 @@ import type { SessionNotification } from '@agentclientprotocol/sdk'; +import type { DelegationUpdateNotification, SessionRuntimeStatus } from '$lib/querymt/generated/types'; import { readAccountingMeta, readUsageBreakdown } from '$lib/domain/session-usage'; import type { ActiveSessionViewModel, @@ -193,7 +194,7 @@ function applySessionNotificationInPlace( target.kind = update.kind ?? target.kind; target.messageId = target.messageId ?? readMessageId(update); target.arguments = stringifyOptional(update.rawInput) ?? target.arguments; - target.result = stringifyToolContent(update.rawOutput ?? update.content) ?? target.result; + mergeToolResult(target, incomingStatus, update.rawOutput ?? update.content); target.eventIndex = target.eventIndex ?? conversationEventIndex; } else { session.toolCalls.push({ @@ -208,15 +209,7 @@ function applySessionNotificationInPlace( }); session.toolCalls = session.toolCalls.slice(); } - const current = session.toolCalls.find((tool) => tool.id === update.toolCallId); - if (current && isTerminalToolStatus(current.status)) { - if (session.activeToolCallId === update.toolCallId) session.activeToolCallId = null; - } else { - session.runState = 'tool-running'; - session.activeToolCallId = update.toolCallId; - session.activityLabel = `Running tool: ${update.title}`; - session.lastError = null; - } + updateToolActivity(session); break; } case 'tool_call_update': { @@ -226,7 +219,7 @@ function applySessionNotificationInPlace( target.status = mergeToolStatus(target.status, update.status ?? target.status); target.kind = update.kind ?? target.kind; target.messageId = target.messageId ?? readMessageId(update); - target.result = stringifyToolContent(update.rawOutput ?? update.content) ?? target.result; + mergeToolResult(target, update.status ?? undefined, update.rawOutput ?? update.content); target.eventIndex = target.eventIndex ?? conversationEventIndex; } else { target = { @@ -241,21 +234,7 @@ function applySessionNotificationInPlace( session.toolCalls.push(target); session.toolCalls = session.toolCalls.slice(); } - if (target.status === 'completed') { - session.runState = 'streaming'; - session.activeToolCallId = null; - session.activityLabel = 'Tool finished. Continuing reply\u2026'; - session.lastError = null; - } else if (target.status === 'failed') { - session.runState = 'failed'; - session.activeToolCallId = update.toolCallId; - session.lastError = update.title ? `${update.title} failed.` : 'Tool call failed.'; - session.activityLabel = session.lastError; - } else { - session.runState = 'tool-running'; - session.activeToolCallId = update.toolCallId; - session.activityLabel = `Running ${update.title ?? 'tool'}\u2026`; - } + updateToolActivity(session); break; } case 'plan': @@ -307,18 +286,14 @@ const STREAM_CHUNK_KINDS = new Set(['agent_message_chunk', 'agent_thought_chunk' function cloneSession(current: ActiveSessionViewModel): ActiveSessionViewModel { return { - sessionId: current.sessionId, + ...current, transcript: current.transcript.map((item) => ({ ...item, blocks: item.blocks?.map((block) => ({ ...block })) })), toolCalls: current.toolCalls.map((item) => ({ ...item })), plans: current.plans.map((item) => ({ ...item })), events: current.events.map((item) => ({ ...item })), configOptions: current.configOptions.map((item) => ({ ...item })), - runState: current.runState, - runStateFromLifecycle: current.runStateFromLifecycle, - activityLabel: current.activityLabel, - activeToolCallId: current.activeToolCallId, - lastStopReason: current.lastStopReason, - lastError: current.lastError, + lastRunOutcome: current.lastRunOutcome ? { ...current.lastRunOutcome } : undefined, + delegations: current.delegations?.map((item) => ({ ...item })), usage: { ...current.usage }, undo: { ...current.undo, @@ -474,7 +449,7 @@ export function applySessionNotification( target.kind = update.kind ?? target.kind; target.messageId = target.messageId ?? readMessageId(update); target.arguments = stringifyOptional(update.rawInput) ?? target.arguments; - target.result = stringifyToolContent(update.rawOutput ?? update.content) ?? target.result; + mergeToolResult(target, incomingStatus, update.rawOutput ?? update.content); target.eventIndex = target.eventIndex ?? conversationEventIndex; } else { next.toolCalls.push({ @@ -488,15 +463,7 @@ export function applySessionNotification( eventIndex: conversationEventIndex }); } - const current = next.toolCalls.find((tool) => tool.id === update.toolCallId); - if (current && isTerminalToolStatus(current.status)) { - if (next.activeToolCallId === update.toolCallId) next.activeToolCallId = null; - } else { - next.runState = 'tool-running'; - next.activeToolCallId = update.toolCallId; - next.activityLabel = `Running tool: ${update.title}`; - next.lastError = null; - } + updateToolActivity(next); break; } case 'tool_call_update': { @@ -506,7 +473,7 @@ export function applySessionNotification( target.status = mergeToolStatus(target.status, update.status ?? target.status); target.kind = update.kind ?? target.kind; target.messageId = target.messageId ?? readMessageId(update); - target.result = stringifyToolContent(update.rawOutput ?? update.content) ?? target.result; + mergeToolResult(target, update.status ?? undefined, update.rawOutput ?? update.content); target.eventIndex = target.eventIndex ?? conversationEventIndex; } else { target = { @@ -520,21 +487,7 @@ export function applySessionNotification( }; next.toolCalls.push(target); } - if (target.status === 'completed') { - next.runState = 'streaming'; - next.activeToolCallId = null; - next.activityLabel = 'Tool finished. Continuing reply…'; - next.lastError = null; - } else if (target.status === 'failed') { - next.runState = 'failed'; - next.activeToolCallId = update.toolCallId; - next.lastError = update.title ? `${update.title} failed.` : 'Tool call failed.'; - next.activityLabel = next.lastError; - } else { - next.runState = 'tool-running'; - next.activeToolCallId = update.toolCallId; - next.activityLabel = `Running ${update.title ?? 'tool'}…`; - } + updateToolActivity(next); break; } case 'plan': @@ -574,6 +527,92 @@ export function applySessionNotification( return next; } +export function getActiveSessionTools(session: ActiveSessionViewModel): SessionToolCallItem[] { + return session.toolCalls.filter((tool) => + (tool.status === 'pending' || tool.status === 'in_progress') && + (!session.runId || !tool.runId || tool.runId === session.runId)); +} + +export function projectSessionRuntime(session: ActiveSessionViewModel, runtime: SessionRuntimeStatus): void { + const previousRunId = session.runId; + if (runtime.active_run_id) session.runId = runtime.active_run_id; + session.activeToolCallId = null; + if (runtime.phase === 'unknown' || runtime.observation?.availability === 'unavailable') { + session.runState = 'unknown'; + session.activityLabel = 'Execution status unavailable'; + endSessionWork(session); + return; + } + if (runtime.phase === 'idle') { + session.toolCalls = session.toolCalls.map((tool) => + (tool.status === 'pending' || tool.status === 'in_progress') && (!tool.runId || tool.runId === previousRunId) + ? { ...tool, status: 'failed', isError: true, result: tool.result ?? 'Execution ended without a recorded tool result.' } + : tool); + const outcome = session.lastRunOutcome; + const matches = outcome && (!previousRunId || !outcome.runId || outcome.runId === previousRunId); + const failed = matches ? outcome.outcome === 'failed' : session.runState === 'failed'; + session.runState = failed ? 'failed' : 'completed'; + session.activityLabel = failed ? 'Turn failed.' : matches && outcome.outcome === 'cancelled' ? 'Turn cancelled.' : 'Turn completed.'; + if (!failed) session.lastError = null; + endSessionWork(session); + return; + } + beginSessionWork(session); + session.lastError = null; + const responding = runtime.phase === 'model' && session.runState === 'streaming' && previousRunId === session.runId; + session.runState = runtime.phase === 'tools' ? 'tool-running' : responding ? 'streaming' : 'thinking'; + const active = getActiveSessionTools(session); + if (runtime.phase === 'tools') session.activeToolCallId = active[0]?.id ?? null; + session.activityLabel = runtime.phase === 'cancel_requested' ? 'Cancelling turn...' + : runtime.phase === 'closing' ? 'Finishing turn...' + : runtime.phase === 'waiting' ? 'Waiting for delegates' + : runtime.phase === 'tools' ? active.length > 1 ? `Running ${active.length} tools` + : active[0] ? `Running tool: ${active[0].title}` : 'Running tools...' + : runtime.phase === 'starting' ? 'Starting turn...' : 'Agent is working...'; +} + +export function mergeDelegationActivity( + current: DelegationUpdateNotification[], + incoming: readonly DelegationUpdateNotification[], + complete = false, + allowNewSource = false +): DelegationUpdateNotification[] { + const items = new Map(current.map((item) => [item.delegationId, item])); + for (const update of incoming) { + const previous = items.get(update.delegationId); + if (previous?.sourceEpoch && previous.sourceEpoch !== update.sourceEpoch && !allowNewSource) continue; + if (previous?.sourceEpoch === update.sourceEpoch && previous?.sessionSeq !== undefined && + (update.sessionSeq === undefined || update.sessionSeq <= previous.sessionSeq)) continue; + if (previous?.activity === 'live' && update.activity !== 'live' && update.sessionSeq === undefined) continue; + items.set(update.delegationId, { ...previous, ...update }); + } + if (complete) { + const liveIds = new Set(incoming.filter((item) => item.activity === 'live').map((item) => item.delegationId)); + for (const [id, item] of items) { + if (item.activity === 'live' && !liveIds.has(id)) items.set(id, { ...item, activity: 'historical' }); + } + } + const merged = [...items.values()]; + return merged.length === current.length && merged.every((item, index) => item === current[index]) ? current : merged; +} + +export function projectDelegationActivity(session: ActiveSessionViewModel): void { + const live = (session.delegations ?? []).filter((item) => item.activity === 'live'); + session.dependencyLabel = live.some((item) => item.stage === 'verifying') ? 'Verifying delegate result' + : live.some((item) => item.stage === 'finalizing') ? 'Finalizing delegate result' + : live.some((item) => item.stage === 'cancel_requested') ? 'Delegate cancellation requested' + : live.length ? `${live.length} delegate${live.length === 1 ? '' : 's'} active` : null; +} + +function updateToolActivity(session: ActiveSessionViewModel): void { + const active = getActiveSessionTools(session); + session.activeToolCallId = active[0]?.id ?? null; + session.runState = active.length ? 'tool-running' : 'streaming'; + session.activityLabel = active.length > 1 ? `Running ${active.length} tools` + : active[0] ? `Running tool: ${active[0].title}` : 'Continuing reply...'; + session.lastError = null; +} + function canonicalizeToolCall(toolCalls: SessionToolCallItem[], toolCallId: string): SessionToolCallItem | null { const matches = toolCalls.filter((tool) => tool.id === toolCallId); if (matches.length === 0) return null; @@ -601,6 +640,13 @@ function canonicalizeToolCall(toolCalls: SessionToolCallItem[], toolCallId: stri return canonical; } +function mergeToolResult(tool: SessionToolCallItem, status: SessionToolCallItem['status'] | undefined, output: unknown): void { + const terminal = status !== undefined && isTerminalToolStatus(status); + // A real terminal result supersedes a provisional interruption error. + tool.result = stringifyToolContent(output) ?? (terminal && tool.isError ? null : tool.result); + if (terminal) tool.isError = status === 'failed'; +} + function mergeToolStatus( current: SessionToolCallItem['status'], incoming: SessionToolCallItem['status'] @@ -823,13 +869,22 @@ export function applyDelegationChildSession( child_session_id?: string | null; state?: SessionToolCallItem['delegationState']; updatedAt?: number; + delegationId?: string; + stage?: DelegationUpdateNotification['stage']; + activity?: DelegationUpdateNotification['activity']; + sessionSeq?: number; + sourceEpoch?: string; } ): ActiveSessionViewModel { const sessionId = readNonEmptyString(update.sessionId) ?? readNonEmptyString(update.session_id); const toolCallId = readNonEmptyString(update.toolCallId) ?? readNonEmptyString(update.tool_call_id); const childSessionId = readNonEmptyString(update.childSessionId) ?? readNonEmptyString(update.child_session_id); - if (!toolCallId) return current; if (sessionId && current.sessionId && sessionId !== current.sessionId) return current; + if (update.delegationId) { + current = { ...current, delegations: mergeDelegationActivity(current.delegations ?? [], [update as DelegationUpdateNotification]) }; + projectDelegationActivity(current); + } + if (!toolCallId) return current; const target = current.toolCalls.find((tool) => tool.id === toolCallId); if (!target) return current; diff --git a/src/lib/domain/sessions.test.ts b/src/lib/domain/sessions.test.ts index 75f50240..b6dcf44f 100644 --- a/src/lib/domain/sessions.test.ts +++ b/src/lib/domain/sessions.test.ts @@ -185,7 +185,7 @@ describe('session relationship metadata', () => { { agentId: 'agent-1', agentName: 'QueryMT' } ); - expect(session.status).toBe('idle'); + expect(session.status).toBe('unknown'); expect(session).toMatchObject({ parentSessionId: 'parent-1', forkOrigin: 'delegation', @@ -279,20 +279,20 @@ describe('workspace location', () => { }); describe('inferSessionStatus', () => { - it('maps active runtime phases to thinking', () => { + it.each(['model', 'tools', 'closing'] as const)('maps active %s runtime to thinking, including closing', (phase) => { expect( inferSessionStatus( createSession({ messageCount: 2, userMessageCount: 1, hasErrors: false, - runtimeStatus: runtimeMeta('model') +runtimeStatus: runtimeMeta(phase) }) ) ).toBe('thinking'); }); - it('maps waiting runtimeStatus to waiting', () => { + it('maps native waiting execution to active, not human input needed', () => { expect( inferSessionStatus( createSession({ @@ -302,7 +302,7 @@ describe('inferSessionStatus', () => { runtimeStatus: runtimeMeta('waiting') }) ) - ).toBe('waiting'); + ).toBe('thinking'); }); it('maps cancel_requested runtimeStatus to cancelling', () => { @@ -344,8 +344,8 @@ describe('inferSessionStatus', () => { ).toBe('idle'); }); - it('falls back to idle when session meta is missing', () => { - expect(inferSessionStatus(createSession())).toBe('idle'); + it('reports unknown when session meta is missing', () => { + expect(inferSessionStatus(createSession())).toBe('unknown'); }); }); diff --git a/src/lib/domain/sessions.ts b/src/lib/domain/sessions.ts index b7ff1e5d..4090d99e 100644 --- a/src/lib/domain/sessions.ts +++ b/src/lib/domain/sessions.ts @@ -221,22 +221,27 @@ export function readSessionLifecycle(namespace: unknown, sessionId: string): Ses export function inferSessionStatus(session: SessionInfo): SessionStatus { const meta = readOperationalSessionMeta(session); if (!meta) { - return 'idle'; + return 'unknown'; } - switch (meta.runtimeStatus.phase) { + return runtimeSessionStatus(meta.runtimeStatus, meta.userMessageCount > 0); +} + +export function runtimeSessionStatus(runtime: QuerymtSessionMeta['runtimeStatus'], hasHistory = true): SessionStatus { + if (runtime.observation?.availability === 'unavailable') return 'unknown'; + switch (runtime.phase) { case QuerymtSessionRuntimePhase.Starting: case QuerymtSessionRuntimePhase.Model: case QuerymtSessionRuntimePhase.Tools: - return 'thinking'; + case QuerymtSessionRuntimePhase.Closing: case QuerymtSessionRuntimePhase.Waiting: - return 'waiting'; + return 'thinking'; case QuerymtSessionRuntimePhase.CancelRequested: return 'cancelling'; case QuerymtSessionRuntimePhase.Idle: - case QuerymtSessionRuntimePhase.Closing: + return hasHistory ? 'completed' : 'idle'; default: - return meta.userMessageCount > 0 ? 'completed' : 'idle'; + return 'unknown'; } } diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts index 08bc07df..97ef8c94 100644 --- a/src/lib/domain/types.ts +++ b/src/lib/domain/types.ts @@ -1,9 +1,10 @@ import type { SessionConfigOption } from '@agentclientprotocol/sdk'; -import type { AccountingSnapshot, DelegationUpdateState } from '$lib/querymt/generated/types'; +import type { AccountingSnapshot, DelegationUpdateNotification, DelegationUpdateState } from '$lib/querymt/generated/types'; export type RuntimeStatus = 'running' | 'starting' | 'degraded' | 'stopped'; -export type SessionStatus = 'idle' | 'thinking' | 'waiting' | 'completed' | 'cancelling'; +export type SessionStatus = 'unknown' | 'idle' | 'thinking' | 'waiting' | 'completed' | 'cancelling'; export type SessionRunState = + | 'unknown' | 'idle' | 'submitting' | 'thinking' @@ -317,6 +318,7 @@ export interface SessionTranscriptGroup { export interface SessionToolCallItem { id: string; + runId?: string | null; title: string; status: 'pending' | 'in_progress' | 'completed' | 'failed'; kind: string | null; @@ -386,6 +388,10 @@ export interface ActiveSessionViewModel { configOptions: SessionConfigOption[]; runState: SessionRunState; runStateFromLifecycle?: boolean; + runId?: string | null; + lastRunOutcome?: { runId: string | null; outcome: string }; + delegations?: DelegationUpdateNotification[]; + dependencyLabel?: string | null; activityLabel: string | null; activeToolCallId: string | null; lastStopReason: string | null; diff --git a/src/lib/querymt/generated/types.ts b/src/lib/querymt/generated/types.ts index 776d21ae..eee85f92 100644 --- a/src/lib/querymt/generated/types.ts +++ b/src/lib/querymt/generated/types.ts @@ -1209,6 +1209,10 @@ export interface DelegationUpdateNotification { delegationId: string; toolCallId?: string; state: DelegationUpdateState; + stage?: 'queued' | 'executing' | 'verifying' | 'finalizing' | 'cancel_requested'; + activity?: 'live' | 'historical'; + sessionSeq?: number; + sourceEpoch?: string; targetAgentId: string; objective: string; childSessionId?: string; @@ -1804,6 +1808,7 @@ export interface SessionLoadSnapshot { accounting: AccountingSnapshot; delegationUpdates: DelegationUpdateNotification[]; userPrompts?: UserPromptRecord[]; + runtimeStatus?: SessionRuntimeStatus; } export enum SessionRuntimePhase { @@ -1814,16 +1819,24 @@ export enum SessionRuntimePhase { Waiting = "waiting", Closing = "closing", CancelRequested = "cancel_requested", + Unknown = "unknown", } /** High-level runtime state for stop/resume and steering orchestration. */ export interface SessionRuntimeStatus { phase: SessionRuntimePhase; - active_run_id?: string; + active_run_id?: string | null; steerable: boolean; pending_steering_count: number; queued_input_count: number; - run_started_at_ms?: number; + run_started_at_ms?: number | null; + observation?: { + availability: 'live' | 'inactive' | 'unavailable'; + epoch?: string; + revision?: number; + reason?: string; + }; + delegation_activity?: DelegationUpdateNotification[]; } export interface SessionMeta { diff --git a/src/lib/querymt/querymt-extensions.ts b/src/lib/querymt/querymt-extensions.ts index 85d52a5d..11682d27 100644 --- a/src/lib/querymt/querymt-extensions.ts +++ b/src/lib/querymt/querymt-extensions.ts @@ -148,6 +148,13 @@ export interface QuerymtWorktreeHandoffRequest { } export const QMT_NOTIFICATION_SESSION_PINS_CHANGED = 'querymt/session/pinsChanged'; export const QMT_NOTIFICATION_SESSION_INPUT_STATE = 'querymt/session/inputState'; +export const QMT_NOTIFICATION_SESSION_RUNTIME_CHANGED = 'querymt/session/runtimeChanged'; + +export interface SessionRuntimeChangedNotification { + version: number; + session_id: string; + runtime: SessionRuntimeStatus; +} export interface QuerymtProfileInfo { id: string; diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts index e1a83fec..5378fa8f 100644 --- a/src/lib/stores/agents.svelte.test.ts +++ b/src/lib/stores/agents.svelte.test.ts @@ -2,6 +2,7 @@ import '@testing-library/jest-dom/vitest'; import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SessionActionsMenu from '$lib/components/primitives/SessionActionsMenu.svelte'; +import SessionActivityBar from '$lib/components/session/SessionActivityBar.svelte'; import { RequestError, type InitializeResponse, type ListSessionsRequest, type PromptResponse, type SessionConfigOption, type SessionInfo, type SessionNotification, type SetSessionConfigOptionRequest } from '@agentclientprotocol/sdk'; import type { AgentConfig, DesktopSessionSummary, ModelEntry, PromptAttachment, PromptSendOptions } from '$lib/domain/types'; import { findModeConfigOption, findReasoningConfigOption, getModelSelectionKey } from '$lib/querymt/config-options'; @@ -9,6 +10,7 @@ import { QMT_METHOD_MESH_NODES, QMT_METHOD_MESH_STATUS } from '$lib/querymt/quer import { RemoteNodeUpgradeRequiredError } from '$lib/domain/remote-errors'; import { DelegateAssignmentSource, + DelegationUpdateState, DelegateReasoningEffort, type AuthProviderStatus, type DelegateAssignmentsInfo, @@ -2569,7 +2571,7 @@ describe('AgentsStore connections', () => { await store.loadSession('agent-1', 'session-1'); expect(mockClient.getUndoStack).toHaveBeenCalledWith('session-1'); - expect(store.activeSession.runState).toBe('completed'); + expect(store.activeSession.runState).toBe('unknown'); expect(store.activeSession.lastError).toBeNull(); expect(store.error).toBeNull(); }); @@ -5865,6 +5867,516 @@ describe('AgentsStore mesh node availability', () => { }); }); +describe('AgentsStore status consistency', () => { + afterEach(cleanup); + + it.each([SessionRuntimePhase.Tools, SessionRuntimePhase.Idle])('F1: rejects stale runtime errors after newer healthy %s', async (phase) => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Model, 5, { active_run_id: 'run-a' })); + let rejectOld!: (error: Error) => void; + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((_, reject) => { rejectOld = reject; })); + const pending = store.refreshSessionRuntime().catch(() => null); + await vi.waitFor(() => expect(rejectOld).toBeDefined()); + announce(runtime(phase, 6)); + rejectOld(new Error('Obsolete request failed')); + await pending; + expect(store.activeSessionRuntime).toMatchObject({ phase, observation: { availability: 'live', revision: 6 } }); + expect(store.activeRunBusy).toBe(phase === SessionRuntimePhase.Tools); + if (phase === SessionRuntimePhase.Tools) expect(store.activeSession.runId).toBe('run-b'); + }); + + it.each(['connection', 'epoch'] as const)('F1: ignores an old error across %s replacement', async (boundary) => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Model, 5, { active_run_id: 'run-a' })); + let rejectOld!: (error: Error) => void; + const replacement = runtime(SessionRuntimePhase.Tools, 1, { observation: { availability: 'live', epoch: 'actor-2', revision: 1 } }); + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((_, reject) => { rejectOld = reject; })) + .mockResolvedValue(replacement); + const pending = store.refreshSessionRuntime().catch(() => null); + await vi.waitFor(() => expect(rejectOld).toBeDefined()); + if (boundary === 'connection') await store.connectAgent('agent-1', true); + announce(replacement); + rejectOld(new Error('Old owner read failed')); + await pending; + await vi.waitFor(() => expect(store.activeSessionRuntime?.observation?.epoch).toBe('actor-2')); + expect(store.activeSessionRuntime).toMatchObject({ phase: SessionRuntimePhase.Tools, observation: { availability: 'live' } }); + expect(store.activeRunBusy).toBe(true); + }); + + it('F3: automatically verifies a nonselected changed incarnation and fences an old E1 response', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Idle, 9)); + await store.loadSession('agent-1', 'other-session'); + mockClient.getSessionRuntimeStatus.mockClear(); + let finishOld!: (value: SessionRuntimeStatus) => void; + let verify!: (value: SessionRuntimeStatus) => void; + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; })) + .mockImplementationOnce(() => new Promise((resolve) => { verify = resolve; })); + const oldRead = store.refreshSessionRuntime('agent-1', 'session-1'); + await vi.waitFor(() => expect(finishOld).toBeDefined()); + const changed = runtime(SessionRuntimePhase.Tools, 1, { observation: { availability: 'live', epoch: 'actor-2', revision: 1 } }); + const listed = (sessionId: string, runtimeStatus: SessionRuntimeStatus) => ({ sessionId, cwd: '/tmp/work', _meta: { + messageCount: 2, userMessageCount: 1, hasErrors: false, runtimeStatus + } }); + mockClient.listSessions.mockResolvedValue({ sessions: [listed('session-1', changed), + ...Array.from({ length: 20 }, (_, index) => listed(`uncached-${index}`, runtime(SessionRuntimePhase.Idle, 1)))] }); + await store.refreshSessionsForAgent('agent-1'); + await vi.waitFor(() => expect(verify).toBeDefined()); + const summary = () => store.sessionsByAgent['agent-1'].find((item) => item.sessionId === 'session-1'); + expect(summary()?.status).toBe('unknown'); + await store.refreshSessionsForAgent('agent-1'); + mockClient.listSessions.mockResolvedValueOnce({ sessions: [listed('session-1', runtime(SessionRuntimePhase.Idle, 10))] }); + await store.refreshSessionsForAgent('agent-1'); + expect(summary()?.status).toBe('unknown'); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledTimes(2); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenNthCalledWith(1, 'session-1'); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenNthCalledWith(2, 'session-1'); + finishOld(runtime(SessionRuntimePhase.Idle, 10)); + expect(await oldRead).toBeNull(); + expect(summary()?.status).toBe('unknown'); + expect(store.sessionRuntimeBySession['agent-1:session-1']?.observation?.availability).toBe('unavailable'); + verify(changed); + await vi.waitFor(() => expect(summary()?.status).toBe('thinking')); + expect(store.sessionRuntimeBySession['agent-1:session-1']).toMatchObject({ phase: SessionRuntimePhase.Tools, + observation: { availability: 'live', epoch: 'actor-2', revision: 1 } }); + expect(store.activeSessionId).toBe('other-session'); + await store.refreshSessionsForAgent('agent-1'); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledTimes(2); + expect(mockClient.loadSession).toHaveBeenCalledTimes(1); + }); + + it('F3: caps changed-incarnation verification at two cached sessions per list', async () => { + const store = await selectedStore(); + await store.loadSession('agent-1', 'other-session'); + store.sessionRuntimeBySession = Object.fromEntries(Array.from({ length: 5 }, (_, index) => + [`agent-1:cached-${index}`, runtime(SessionRuntimePhase.Idle, 9)])); + mockClient.getSessionRuntimeStatus.mockClear(); + mockClient.getSessionRuntimeStatus.mockImplementation(() => new Promise(() => {})); + mockClient.listSessions.mockResolvedValue({ sessions: Array.from({ length: 5 }, (_, index) => ({ + sessionId: `cached-${index}`, cwd: '/tmp/work', _meta: { runtimeStatus: runtime(SessionRuntimePhase.Tools, 1, + { observation: { availability: 'live', epoch: 'actor-2', revision: 1 } }) } + })) }); + await store.refreshSessionsForAgent('agent-1'); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledTimes(2); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenNthCalledWith(1, 'cached-0'); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenNthCalledWith(2, 'cached-1'); + expect(store.sessionsByAgent['agent-1'].filter((item) => item.sessionId.startsWith('cached-')).map((item) => item.status)) + .toEqual(Array(5).fill('unknown')); + }); + + it.each([ + { delivery: 'queue', boundary: 'selection', failure: false }, + { delivery: 'steer', boundary: 'connection', failure: false }, + { delivery: 'queue', boundary: 'selection', failure: true }, + { delivery: 'steer', boundary: 'connection', failure: true } + ] as const)('F4: fences $delivery visible effects after $boundary replacement (failure=$failure) but retains receipts', async ({ delivery, boundary, failure }) => { + const store = await selectedStore(); + mockClient.supportsQuerymtFeature.mockImplementation((feature) => feature === 'steering'); + store.setActiveComposerInputDelivery(delivery); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Model, 1)); + let resolve!: (result: SubmitInputResult) => void; + let reject!: (error: Error) => void; + const deferred = new Promise((yes, no) => { resolve = yes; reject = no; }); + if (delivery === 'queue') mockClient.queueSession.mockImplementationOnce(() => deferred); + else mockClient.steerSession.mockImplementationOnce(() => deferred); + const submitting = store.sendPromptToActiveSession(); + await vi.waitFor(() => expect(store.activePendingInputs).toHaveLength(1)); + const inputId = store.activePendingInputs[0].inputId; + const acceptedBeforeFailure = delivery === 'steer' && failure; + if (acceptedBeforeFailure) mockClient.emitExtensionNotification({ method: 'querymt/session/inputState', params: { + version: 1, session_id: 'session-1', input_id: inputId, delivery: SessionInputDelivery.Steer, + state: SessionInputState.Accepted, run_id: 'run-b' + } }); + if (boundary === 'selection') { + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 3)); + await store.loadSession('agent-1', 'other-session'); + await store.loadSession('agent-1', 'session-1'); + } else { + await store.connectAgent('agent-1', true); + announce(runtime(SessionRuntimePhase.Idle, 3)); + } + const draft = store.composerPrompt; + store.error = 'Latest view error'; + store.activeSession.lastError = 'Latest execution detail'; + if (failure) reject(new Error('Obsolete submission failure')); + else resolve(delivery === 'queue' ? { status: 'queued', data: { input_id: inputId, position: 1 } } + : { status: 'steered', data: { input_id: inputId, run_id: 'run-b', position: 1 } }); + await submitting; + expect(store.composerPrompt).toBe(draft); + expect(store.error).toBe('Latest view error'); + expect(store.activeSession.runState).toBe('completed'); + expect(store.activeSession.lastError).toBe('Latest execution detail'); + expect(store.pendingInputsBySession['agent-1:session-1'].find((item) => item.inputId === inputId)?.state) + .toBe(acceptedBeforeFailure ? 'accepted' : failure ? 'failed' : delivery === 'queue' ? 'queued' : 'accepted'); + }); + + it('F5: fails closed on unavailable Send without silently queueing or losing the draft', async () => { + const store = await selectedStore(); + mockClient.supportsQuerymtFeature.mockImplementation((feature) => feature === 'steering'); + const draft = store.composerPrompt; + const attachments = [{ id: 'notes', name: 'notes.txt', mimeType: 'text/plain', size: 1, data: 'eA==' }]; + store.promptAttachments = attachments; + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Unknown, 1, { + active_run_id: null, steerable: false, observation: { availability: 'unavailable' } + })); + await store.sendPromptToActiveSession(); + expect(mockClient.sendPrompt).not.toHaveBeenCalled(); + expect(mockClient.steerSession).not.toHaveBeenCalled(); + expect(mockClient.queueSession).not.toHaveBeenCalled(); + expect(store.composerPrompt).toBe(draft); + expect(store.promptAttachments).toEqual(attachments); + expect(store.activeSession.transcript).toHaveLength(0); + expect(store.error).toMatch(/status unavailable/i); + }); + + it('retains a same-run initiating cancellation outcome with versioned runtime', async () => { + const store = await selectedStore(); + let finish!: (response: PromptResponse) => void; + mockClient.sendPrompt.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const sending = store.sendPromptToActiveSession(); + await vi.waitFor(() => expect(finish).toBeDefined()); + announce(runtime(SessionRuntimePhase.Model, 1, { active_run_id: 'run-a' })); + announce(runtime(SessionRuntimePhase.Idle, 2)); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 2)); + finish({ stopReason: 'cancelled' }); + await sending; + expect(store.activeSession).toMatchObject({ runId: 'run-a', runState: 'completed', lastStopReason: 'cancelled', + lastRunOutcome: { runId: 'run-a', outcome: 'cancelled' }, activityLabel: 'Turn cancelled.' }); + }); + function runtime(phase: SessionRuntimePhase, revision: number, extra: Partial = {}): SessionRuntimeStatus { + return { phase, active_run_id: phase === SessionRuntimePhase.Idle ? undefined : 'run-b', + steerable: phase !== SessionRuntimePhase.Closing, pending_steering_count: 0, queued_input_count: 0, + observation: { availability: 'live', epoch: 'actor-1', revision }, delegation_activity: [], ...extra }; + } + + async function selectedStore() { + const store = createStore(); + await store.connectAgent('agent-1', true); + store.activeAgentId = 'agent-1'; + store.activeSessionId = 'session-1'; + store.activeSession.sessionId = 'session-1'; + return store; + } + + function announce(value: SessionRuntimeStatus) { + mockClient.emitExtensionNotification({ method: 'querymt/session/runtimeChanged', + params: { version: 1, session_id: 'session-1', runtime: value } }); + } + + const verifier = { version: 1, sessionId: 'session-1', delegationId: 'delegate-1', + state: DelegationUpdateState.Forked, targetAgentId: 'coder', objective: '', requestedAt: 1, updatedAt: 2, + stage: 'verifying', activity: 'live', sourceEpoch: 'workflow-1', sessionSeq: 10 } as const; + + it('merges independently ordered stages and empty authoritative activity at equal self revision', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Idle, 5, { delegation_activity: [verifier] })); + expect(store.activeRunBusy).toBe(false); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 5, { + delegation_activity: [{ ...verifier, stage: 'finalizing', sessionSeq: 11 }] + })); + await store.refreshSessionRuntime(); + expect(store.activeSession.dependencyLabel).toBe('Finalizing delegate result'); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 5)); + await store.refreshSessionRuntime(); + expect(store.activeSession.dependencyLabel).toBeNull(); + // An old live observation cannot resurrect a workflow removed by a newer query. + announce(runtime(SessionRuntimePhase.Idle, 5, { delegation_activity: [verifier] })); + expect(store.activeSession.dependencyLabel).toBeNull(); + }); + + it('retains a live verifier through unavailable empty runtime and same-sequence recovery until healthy cleanup', async () => { + const store = await selectedStore(); + const liveVerifier = { ...verifier, delegationId: 'verifier-b', sessionSeq: 17 }; + announce(runtime(SessionRuntimePhase.Idle, 5, { delegation_activity: [liveVerifier] })); + expect(store.activeRunBusy).toBe(false); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(runtime(SessionRuntimePhase.Unknown, 0, { + active_run_id: null, steerable: false, observation: { availability: 'unavailable', reason: 'owner_unavailable' } + })); + await store.refreshSessionRuntime(); + expect(store.activeSessionRuntime).toMatchObject({ phase: SessionRuntimePhase.Unknown, + observation: { availability: 'unavailable' } }); + expect(store.activeSession.runState).toBe('unknown'); + expect.soft(store.activeSession.delegations).toContainEqual(liveVerifier); + expect.soft(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(runtime(SessionRuntimePhase.Idle, 5, { + delegation_activity: [liveVerifier] + })); + await store.refreshSessionRuntime(); + expect(store.activeSessionRuntime?.phase).toBe(SessionRuntimePhase.Idle); + expect(store.activeRunBusy).toBe(false); + expect(store.activeSession.delegations).toContainEqual(liveVerifier); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(runtime(SessionRuntimePhase.Idle, 5)); + await store.refreshSessionRuntime(); + expect(store.activeSession.dependencyLabel).toBeNull(); + expect(store.activeSession.delegations).toContainEqual({ ...liveVerifier, activity: 'historical' }); + expect(store.activeSessionRuntime?.delegation_activity).toEqual([]); + }); + + it('does not erase a newer live workflow with an older in-flight empty activity response', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Idle, 5)); + let finish!: (value: SessionRuntimeStatus) => void; + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const pending = store.refreshSessionRuntime(); + await vi.waitFor(() => expect(finish).toBeDefined()); + mockClient.emitExtensionNotification({ method: 'querymt/session/delegationUpdate', params: verifier }); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + finish(runtime(SessionRuntimePhase.Idle, 5)); + await pending; + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 5)); + await store.refreshSessionRuntime(); + expect(store.activeSession.dependencyLabel).toBeNull(); + }); + + it('accepts newer list run B instead of blindly preferring cached idle A', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Idle, 1)); + mockClient.listSessions.mockResolvedValue({ sessions: [{ sessionId: 'session-1', cwd: '/tmp/work', _meta: { + messageCount: 2, userMessageCount: 1, hasErrors: false, runtimeStatus: runtime(SessionRuntimePhase.Tools, 2) + } }] }); + await store.refreshSessionsForAgent('agent-1'); + expect(store.activeSessionRuntime?.active_run_id).toBe('run-b'); + expect(store.activeRunBusy).toBe(true); + expect(store.sessionsByAgent['agent-1'][0].status).toBe('thinking'); + mockClient.listSessions.mockResolvedValue({ sessions: [{ sessionId: 'session-1', cwd: '/tmp/work', _meta: { + messageCount: 2, userMessageCount: 1, hasErrors: false, runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) + } }] }); + await store.refreshSessionsForAgent('agent-1'); + expect(store.activeSessionRuntime?.active_run_id).toBe('run-b'); + }); + + it('converges observer-only start/completion and ignores equal/older execution revisions', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Tools, 2)); + expect(store.activeRunBusy).toBe(true); + store.activeSession.activityLabel = 'Detail retained'; + announce(runtime(SessionRuntimePhase.Tools, 2)); + expect(store.activeSession.activityLabel).toBe('Detail retained'); + announce(runtime(SessionRuntimePhase.Idle, 1)); + expect(store.activeRunBusy).toBe(true); + announce(runtime(SessionRuntimePhase.Idle, 3)); + expect(store.activeRunBusy).toBe(false); + expect(store.activeSession.runState).toBe('completed'); + expect(mockClient.sendPrompt).not.toHaveBeenCalled(); + }); + + it.each(['tool_call', 'tool_call_update'] as const)('does not let completed snapshot A clobber captured live shell run B (%s)', async (captureKind) => { + const store = await selectedStore(); + mockClient.loadSession.mockImplementationOnce(async () => { + announce(runtime(SessionRuntimePhase.Tools, 3)); + return { response: { configOptions: [], _meta: { 'querymt/sessionLoadSnapshot.v1': { + runtimeStatus: runtime(SessionRuntimePhase.Idle, 2), audit: { events: [ + { seq: 1, kind: { type: 'run_started', data: { run_id: 'run-a' } } }, + { seq: 2, kind: { type: 'assistant_message_stored', data: { message_id: 'answer-a', content: 'Done A' } } }, + { seq: 3, kind: { type: 'run_completed', data: { run_id: 'run-a', outcome: 'completed' } } } + ] } + } } }, replay: [{ sessionId: 'session-1', update: { + sessionUpdate: captureKind, toolCallId: 'shell-b', title: 'Long shell B', status: 'in_progress' + } }] }; + }); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Tools, 3)); + await store.loadSession('agent-1', 'session-1'); + expect(store.activeSession).toMatchObject({ runId: 'run-b', runState: 'tool-running', activeToolCallId: 'shell-b' }); + expect(store.activeSession.toolCalls[0].status).toBe('in_progress'); + render(SessionActivityBar, { session: store.activeSession }); + expect(screen.getByRole('status')).toHaveTextContent(/Long shell B/i); + cleanup(); + }); + + it('accepts the actual tool result when runtime idle arrives ahead of the terminal tool update', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Tools, 1)); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'tool_call', toolCallId: 'late-shell', title: 'Shell', status: 'in_progress' + } }); + announce(runtime(SessionRuntimePhase.Idle, 2)); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'tool_call_update', toolCallId: 'late-shell', status: 'completed', rawOutput: 'Actual result' + } }); + expect(store.activeSession.toolCalls[0]).toMatchObject({ status: 'completed', result: 'Actual result' }); + expect(store.activeSession.toolCalls[0].isError).not.toBe(true); + expect(store.activeRunBusy).toBe(false); + }); + + it('does not let a run A Stop failure erase newer healthy run B', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Tools, 1, { active_run_id: 'run-a' })); + let rejectCancel!: (error: Error) => void; + mockClient.cancelSession.mockImplementationOnce(() => new Promise((_, reject) => { rejectCancel = reject; })); + const stopping = store.cancelActiveSession(); + await vi.waitFor(() => expect(rejectCancel).toBeDefined()); + announce(runtime(SessionRuntimePhase.Tools, 3)); + store.error = 'Latest view error'; + rejectCancel(new Error('Old cancel send failed')); + await stopping; + expect(store.activeSession).toMatchObject({ runId: 'run-b', runState: 'tool-running' }); + expect(store.activeRunBusy).toBe(true); + expect(store.error).toBe('Latest view error'); + }); + + it('distinguishes native waiting self execution from own-idle managed verification and historical rows', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Waiting, 1, { delegation_activity: [{ ...verifier, stage: 'executing' }] })); + expect(store.activeRunBusy).toBe(true); + const view = render(SessionActivityBar, { session: store.activeSession }); + expect(screen.getByRole('status')).toHaveTextContent('Waiting for delegates'); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.CancelRequested, 2)); + await store.cancelActiveSession(); + expect(mockClient.cancelSession).toHaveBeenCalledWith('session-1'); + expect(mockClient.discardQueuedInput).not.toHaveBeenCalled(); + announce(runtime(SessionRuntimePhase.Idle, 3, { delegation_activity: [{ ...verifier, sessionSeq: 11 }] })); + expect(store.activeRunBusy).toBe(false); + await view.rerender({ session: store.activeSession }); + expect(screen.getByRole('status')).toHaveTextContent('Own turn idle - Verifying delegate result'); + expect(screen.getByRole('status')).not.toHaveTextContent('Double Esc'); + await store.cancelActiveSession(); + expect(mockClient.cancelSession).toHaveBeenCalledTimes(1); + mockClient.emitExtensionNotification({ method: 'querymt/session/delegationUpdate', params: { + ...verifier, state: DelegationUpdateState.Completed, stage: undefined, activity: 'historical', sessionSeq: 12 + } }); + expect(store.activeSession.dependencyLabel).toBeNull(); + mockClient.emitExtensionNotification({ method: 'querymt/session/delegationUpdate', params: { + ...verifier, activity: 'historical', sourceEpoch: 'old-source', sessionSeq: 999 + } }); + expect(store.activeSession.dependencyLabel).toBeNull(); + cleanup(); + }); + + it.each([false, true])('does not let run A prompt completion erase new queued run B (B completed=%s)', async (bCompleted) => { + const store = await selectedStore(); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 0)); + let finish!: (value: PromptResponse) => void; + mockClient.sendPrompt.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const sending = store.sendPromptToActiveSession(); + await vi.waitFor(() => expect(finish).toBeDefined()); + announce(runtime(SessionRuntimePhase.Model, 1, { active_run_id: 'run-a' })); + announce(runtime(SessionRuntimePhase.Tools, 3)); + store.pendingInputsBySession = { 'agent-1:session-1': [{ inputId: 'queued-c', delivery: 'queue', state: 'queued', + prompt: 'Next', attachments: [], createdAt: 1 }] }; + if (bCompleted) announce(runtime(SessionRuntimePhase.Idle, 4)); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(bCompleted ? SessionRuntimePhase.Idle : SessionRuntimePhase.Tools, bCompleted ? 4 : 3)); + finish({ stopReason: 'cancelled' }); + await sending; + expect(store.activeSession).toMatchObject({ runId: 'run-b', runState: bCompleted ? 'completed' : 'tool-running', lastStopReason: null }); + expect(store.activeSession.activityLabel).not.toContain('cancelled'); + expect(store.activePendingInputs.map((item) => item.inputId)).toEqual(['queued-c']); + expect(mockClient.discardQueuedInput).not.toHaveBeenCalled(); + }); + + it('rejects a pre-selection A read across A -> B -> A and restores the new A execution', async () => { + const store = await selectedStore(); + let finishOld!: (value: SessionRuntimeStatus) => void; + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; })) + .mockResolvedValueOnce(runtime(SessionRuntimePhase.Idle, 1)) + .mockResolvedValueOnce(runtime(SessionRuntimePhase.Tools, 5)); + const oldRead = store.refreshSessionRuntime(); + await vi.waitFor(() => expect(finishOld).toBeDefined()); + await store.loadSession('agent-1', 'session-other'); + const returning = store.loadSession('agent-1', 'session-1'); + finishOld(runtime(SessionRuntimePhase.Idle, 2)); + expect(await oldRead).toBeNull(); + await returning; + expect(store.activeSession).toMatchObject({ sessionId: 'session-1', runId: 'run-b', runState: 'tool-running' }); + }); + + it('requires a fresh read for an incarnation change and rejects a pre-change response', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Tools, 5)); + let finishOld!: (value: SessionRuntimeStatus) => void; + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((resolve) => { finishOld = resolve; })) + .mockResolvedValueOnce(runtime(SessionRuntimePhase.Waiting, 1, { observation: { availability: 'live', epoch: 'actor-2', revision: 1 } })); + const oldRead = store.refreshSessionRuntime(); + await vi.waitFor(() => expect(finishOld).toBeDefined()); + announce(runtime(SessionRuntimePhase.Waiting, 1, { observation: { availability: 'live', epoch: 'actor-2', revision: 1 } })); + finishOld(runtime(SessionRuntimePhase.Idle, 6)); + expect(await oldRead).toBeNull(); + await vi.waitFor(() => expect(store.activeSessionRuntime?.observation?.epoch).toBe('actor-2')); + expect(store.activeSession.activityLabel).toBe('Waiting for delegates'); + expect(store.activeRunBusy).toBe(true); + }); + + it.each(['idle', 'tools'] as const)('reconciles %s runtime into the visible execution view', async (phase) => { + const store = createStore(); + await store.connectAgent('agent-1', true); + store.activeAgentId = 'agent-1'; + store.activeSessionId = 'session-1'; + store.activeSession.sessionId = 'session-1'; + store.activeSession.runState = phase === 'idle' ? 'thinking' : 'completed'; + store.activeSession.activityLabel = phase === 'idle' ? 'Cancelling turn...' : 'Turn completed.'; + mockClient.getSessionRuntimeStatus.mockResolvedValue({ + phase: phase === 'idle' ? SessionRuntimePhase.Idle : SessionRuntimePhase.Tools, + active_run_id: phase === 'tools' ? 'run-b' : undefined, + steerable: phase === 'tools', pending_steering_count: 0, queued_input_count: 0 + }); + await store.refreshSessionRuntime(); + expect(store.activeSession.runState).toBe(phase === 'idle' ? 'completed' : 'tool-running'); + if (phase === 'idle') expect(store.activeSession.activityLabel).not.toContain('Cancelling'); + else { + await store.cancelActiveSession(); + expect(mockClient.cancelSession).toHaveBeenCalledWith('session-1'); + } + }); + + it('restores legacy replay-only tools and does not promote historical delegate rows to live work', async () => { + const store = await selectedStore(); + mockClient.loadSession.mockResolvedValueOnce({ response: { configOptions: [], _meta: { + 'querymt/sessionLoadSnapshot.v1': { delegationUpdates: [{ ...verifier, activity: 'historical' }] } + } }, replay: [{ sessionId: 'session-1', update: { + sessionUpdate: 'tool_call', toolCallId: 'legacy-shell', title: 'Legacy shell', status: 'in_progress' + } }] }); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Tools, 1)); + await store.loadSession('agent-1', 'session-1'); + expect(store.activeSession).toMatchObject({ runState: 'tool-running', activeToolCallId: 'legacy-shell', dependencyLabel: null }); + expect(store.activeSession.toolCalls[0].status).toBe('in_progress'); + expect(store.activeSession.delegations?.[0].activity).toBe('historical'); + }); + + it('keeps closing active, visible and non-steerable while unknown is not idle', async () => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Closing, 1)); + expect(store.activeRunBusy).toBe(true); + expect(store.activeInputDelivery).toBe('queue'); + const view = render(SessionActivityBar, { session: store.activeSession }); + expect(screen.getByRole('status')).toHaveTextContent('Finishing turn'); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Unknown, 2, { + active_run_id: undefined, observation: { availability: 'unavailable', epoch: 'actor-1', revision: 2 } + })); + await store.refreshSessionRuntime(); + await view.rerender({ session: store.activeSession }); + expect(store.activeSession.runState).toBe('unknown'); + expect(store.activeRunBusy).toBe(false); + expect(screen.getByRole('status')).toHaveTextContent('Execution status unavailable'); + expect(screen.getByRole('status')).not.toHaveTextContent('Double Esc'); + }); + + it('preserves a running shell through the real load path', async () => { + const store = createStore(); + mockClient.loadSession.mockResolvedValue({ response: { configOptions: [], _meta: { + 'querymt/sessionLoadSnapshot.v1': { audit: { events: [ + { seq: 1, kind: { type: 'run_started', data: { run_id: 'run-b' } } }, + { seq: 2, kind: { type: 'tool_call_start', data: { tool_call_id: 'shell', tool_name: 'shell' } } } + ] } } + } }, replay: [] }); + mockClient.getSessionRuntimeStatus.mockResolvedValue({ + phase: SessionRuntimePhase.Tools, active_run_id: 'run-b', steerable: true, + pending_steering_count: 0, queued_input_count: 0, run_started_at_ms: 1 + }); + const loadingAt = Date.now(); + await store.loadSession('agent-1', 'session-1'); + expect(store.activeSession.toolCalls[0]).toMatchObject({ id: 'shell', status: 'in_progress' }); + expect(store.activeSession.runState).toBe('tool-running'); + expect(store.activeSession.activeToolCallId).toBe('shell'); + expect(store.activeSession.usage.activeWorkStartedAt).toBeGreaterThanOrEqual(loadingAt); + }); +}); + describe('AgentsStore prompt session start', () => { it.each(['capture', 'live', 'drain'] as const)('keeps one first prompt when navigation overlaps a snapshot with its %s echo', async (delivery) => { const store = createStore(); @@ -7374,7 +7886,9 @@ describe('AgentsStore prompt session start', () => { await store.cancelActiveSession(); - expect(store.activeSession.activityLabel).toBe('Cancelling turn…'); + expect(store.activeSession.activityLabel).toBe('Turn completed.'); + expect(store.activeSession.runState).toBe('completed'); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledWith('session-1'); expect(store.activeSession.lastError).toBe(null); expect(mockClient.cancelSession).toHaveBeenCalledWith('session-1'); }); diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts index 84b272a9..e0598855 100644 --- a/src/lib/stores/agents.svelte.ts +++ b/src/lib/stores/agents.svelte.ts @@ -37,10 +37,13 @@ import { type PromptSendInput, type SessionCreateOptions } from '$lib/domain/session-launch'; -import { activeSessionFromLoadResponse, getSnapshotInputStates, getSnapshotProviderChange, normalizeHistoricalSession } from '$lib/domain/session-snapshot'; +import { activeSessionFromLoadResponse, getSnapshotInputStates, getSnapshotRuntimeStatus, getSnapshotProviderChange, normalizeHistoricalSession } from '$lib/domain/session-snapshot'; import { canUndoToMessage, getCurrentUndoTarget, getUndoableSessionTurns } from '$lib/domain/session-undo'; import { createEmptyActiveSession, + projectSessionRuntime, + projectDelegationActivity, + mergeDelegationActivity, appendSessionReplay, applySessionNotification, applyDelegationChildSession, @@ -71,6 +74,7 @@ import { getWorkspaceRemoteMachines, isActiveSessionStatus, mapAcpSessionsToDesktopSessions, + runtimeSessionStatus, readSessionRelationshipMeta, newestTimestamp, type WorkspaceSessionGroup, @@ -123,6 +127,7 @@ import type { ScheduleListInfo, SessionInputStateNotification, SessionRuntimeStatus, + DelegationUpdateNotification, SetDelegateModelResponse, SubmitInputResult } from '$lib/querymt/generated/types'; @@ -160,6 +165,8 @@ import { QMT_METHOD_SESSION_UNDO, QMT_METHOD_SESSION_UNDO_STACK, QMT_NOTIFICATION_SESSION_INPUT_STATE, + QMT_NOTIFICATION_SESSION_RUNTIME_CHANGED, + type SessionRuntimeChangedNotification, QMT_NOTIFICATION_SESSION_PINS_CHANGED } from '$lib/querymt/querymt-extensions'; import { sendDesktopNotification } from '$lib/querymt/notifications'; @@ -214,6 +221,7 @@ const WEBSOCKET_RECONNECT_MAX_DELAY_MS = 8_000; const WORKSPACE_SESSION_PAGE_SIZE = 10; export const SESSION_SUMMARY_POLL_MS = 30_000; export const SESSION_RUNTIME_POLL_MS = 2_000; +const MAX_LIST_INCARNATION_VERIFICATIONS = 2; const MAX_AGENT_LOG_ENTRIES = 200; const PROMPT_ACTIVE_RUN_STATES = new Set(['thinking', 'streaming', 'tool-running']); @@ -309,9 +317,15 @@ export class AgentsStore { private runtimePollingEnabled = false; private unlistenPollingVisibility: (() => void) | null = null; private routineSessionRefreshes = new Map>(); + private sessionDelegations = new Map(); private sessionRuntimeRefreshTimers = new Map>(); private sessionRuntimeRefreshGenerations = new Map(); - private sessionRuntimeRefreshPromises = new Map }>(); + private sessionRuntimeRefreshPromises = new Map; + }>(); + // Request-scoped identities only; the runtime cache remains execution authority. + private pendingPromptRuns = new Map>(); private inputSubmissionsBySession = new Map(); private inputNotificationsDuringSubmit = new Map(); private reconnectTimers = new Map>(); @@ -502,6 +516,10 @@ export class AgentsStore { return this.sessionRuntimeBySession[buildSessionKey(this.activeAgentId, this.activeSessionId)] ?? null; } + get activeRunBusy(): boolean { + return !this.sessionHistoryLoading && (PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState) || this.activeSession.runState === 'submitting'); + } + get activePendingInputs(): PendingSessionInput[] { if (!this.activeAgentId || !this.activeSessionId) return []; return this.pendingInputsBySession[buildSessionKey(this.activeAgentId, this.activeSessionId)] ?? []; @@ -594,7 +612,40 @@ export class AgentsStore { } private mapSessionSummaries(sessions: SessionInfo[], agent: { agentId: string; agentName: string }): DesktopSessionSummary[] { - const incoming = mapAcpSessionsToDesktopSessions(sessions, agent); + let incarnationVerifications = 0; + const record = this.clients.get(agent.agentId); + for (const info of sessions) { + const runtime = info._meta?.runtimeStatus as SessionRuntimeStatus | undefined; + if (runtime?.observation) { + const key = buildSessionKey(agent.agentId, info.sessionId); + const pending = this.sessionRuntimeRefreshPromises.get(key); + const verifying = pending?.verificationEpoch && pending.record === record && + pending.selection === this.sessionSelectionGeneration && + pending.generation === this.sessionRuntimeRefreshGenerations.get(key); + // List evidence cannot end a pending incarnation verification, even if an old list arrives. + const accepted = !verifying && this.reconcileSessionRuntime(agent.agentId, info.sessionId, runtime); + const observed = this.sessionRuntimeBySession[key]; + if (!accepted && observed?.observation?.epoch !== runtime.observation.epoch) { + if (this.isSelectedSession(agent.agentId, info.sessionId)) { + if (record) void this.refreshSessionRuntime(agent.agentId, info.sessionId, record.client, true).catch(() => null); + } else { + this.markSessionRuntimeUnavailable(agent.agentId, info.sessionId); + if (!verifying || pending.verificationEpoch !== runtime.observation.epoch) { + // Fence pre-change reads before starting a bounded, deduplicated replacement. + this.sessionRuntimeRefreshGenerations.set(key, (this.sessionRuntimeRefreshGenerations.get(key) ?? 0) + 1); + if (record && runtime.observation.epoch && incarnationVerifications < MAX_LIST_INCARNATION_VERIFICATIONS) { + incarnationVerifications++; + void this.refreshSessionRuntime(agent.agentId, info.sessionId, record.client, false, runtime.observation.epoch).catch(() => null); + } + } + } + } + } + } + const incoming = mapAcpSessionsToDesktopSessions(sessions, agent).map((summary) => { + const runtime = this.sessionRuntimeBySession[buildSessionKey(agent.agentId, summary.sessionId)]; + return runtime?.observation ? { ...summary, status: runtimeSessionStatus(runtime) } : summary; + }); const incomingKeys = new Set(incoming.map(getSessionKey)); const summaries = mergeSessions(this.sessionsByAgent[agent.agentId] ?? [], incoming) .filter((session) => incomingKeys.has(getSessionKey(session))); @@ -954,12 +1005,13 @@ export class AgentsStore { private selectedRuntimeNeeded(): boolean { if (!this.activeAgentId || !this.activeSessionId || isPendingLaunchId(this.activeSessionId)) return false; const runtime = this.sessionRuntimeBySession[buildSessionKey(this.activeAgentId, this.activeSessionId)]; - return (runtime != null && runtime.phase !== 'idle') || PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState) || + const dependencies = this.sessionDelegations.get(buildSessionKey(this.activeAgentId, this.activeSessionId)); + return (runtime != null && runtime.phase !== 'idle') || dependencies?.some((item) => item.activity === 'live') === true || PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState) || this.activeSession.runState === 'submitting' || this.activePendingInputs.length > 0; } private resumeSelectedRuntime() { - if (!this.runtimePollingEnabled || !this.pollingIsVisible() || !this.selectedRuntimeNeeded()) return; + if (!this.runtimePollingEnabled || !this.pollingIsVisible() || !this.activeSessionId) return; const agentId = this.activeAgentId!; const record = this.initializedClientRecord(agentId); if (record) void this.refreshSessionRuntime(agentId, this.activeSessionId, record.client).catch(() => null); @@ -3569,6 +3621,7 @@ export class AgentsStore { if (explicit && turnEventIndex === undefined) { turnEventIndex = this.addOptimisticUserPrompt(sessionId, prompt, attachments, clientPromptId); } + let promptRun: { runId: string | null } | null = null; try { const record = await this.connectInitializedRecord(agentId); @@ -3594,6 +3647,11 @@ export class AgentsStore { return; } if (canControlRun && !runtime && !explicit) return; + const observation = runtime ?? this.activeSessionRuntime; + if (observation?.phase === 'unknown' || observation?.observation?.availability === 'unavailable') { + this.error = 'Execution status unavailable. Reconnect or refresh before sending.'; + return; + } if (runtime && runtime.phase !== 'idle') { const delivery = this.inputDeliveryForSession(agentId, sessionId, runtime, prompt); await this.submitInputToActiveSession( @@ -3605,7 +3663,8 @@ export class AgentsStore { prompt, attachments, clientPromptId, - imageMode + imageMode, + ownsAction ); return; } @@ -3640,13 +3699,18 @@ export class AgentsStore { } // From here the session's own failure/retry UI owns the prompt. if (explicit) this.markLaunchDispatched(explicit.launchId, true); + promptRun = { runId: null }; + const promptKey = buildSessionKey(agentId, sessionId); + const pendingRuns = this.pendingPromptRuns.get(promptKey) ?? new Set<{ runId: string | null }>(); + pendingRuns.add(promptRun); + this.pendingPromptRuns.set(promptKey, pendingRuns); const promptResponse = record.client.sendPrompt(sessionId, prompt, attachments, { imageMode, clientPromptId }); this.scheduleSessionRuntimeRefresh(agentId, sessionId, record.client); const response = await promptResponse; - if (!this.isSelectedSession(agentId, sessionId)) { + if (!ownsAction()) { await this.refreshSessionsForAgent(agentId); if (canControlRun) void this.refreshSessionRuntime(agentId, sessionId, record.client); return; @@ -3656,18 +3720,25 @@ export class AgentsStore { this.composerPrompt = ''; this.clearPromptAttachments(); } - this.activeSession.lastStopReason = response.stopReason ?? null; - endSessionWork(this.activeSession); await this.drainQueuedSessionUpdates(agentId, sessionId); - if (!this.isSelectedSession(agentId, sessionId)) return; - if (PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState)) { + if (!ownsAction()) return; + const versionedRuntime = this.activeSessionRuntime?.observation; + if (versionedRuntime && promptRun.runId && this.activeSession.runId === promptRun.runId) { + this.activeSession.lastStopReason = response.stopReason ?? null; + this.activeSession.lastRunOutcome = { runId: promptRun.runId, outcome: response.stopReason === 'cancelled' ? 'cancelled' : 'completed' }; + this.projectSelectedExecution(agentId, sessionId); + } + if (!versionedRuntime && PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState)) { + this.activeSession.lastStopReason = response.stopReason ?? null; + this.activeSession.lastRunOutcome = { runId: this.activeSession.runId ?? null, outcome: response.stopReason === 'cancelled' ? 'cancelled' : 'completed' }; + endSessionWork(this.activeSession); this.activeSession.runState = 'completed'; this.activeSession.activeToolCallId = null; this.activeSession.activityLabel = response.stopReason === 'cancelled' ? 'Turn cancelled.' : 'Turn completed.'; } await this.refreshSessionsForAgent(agentId); - if (canControlRun) void this.refreshSessionRuntime(agentId, sessionId, record.client); + if (record.client.supportsQuerymtMethod(QMT_METHOD_SESSION_RUNTIME_STATE)) await this.refreshSessionRuntime(agentId, sessionId, record.client, true).catch(() => null); } catch (error) { if (!ownsAction()) { if (explicit && this.pendingLaunches[explicit.launchId]?.status === 'creating') { @@ -3724,6 +3795,12 @@ export class AgentsStore { }; this.activeSession.lastError = normalizedError.message; this.activeSession.activityLabel = normalizedError.title; + if (this.activeSessionRuntime?.observation) this.projectSelectedExecution(agentId, sessionId); + } finally { + const key = buildSessionKey(agentId, sessionId); + const pendingRuns = this.pendingPromptRuns.get(key); + if (promptRun) pendingRuns?.delete(promptRun); + if (pendingRuns?.size === 0) this.pendingPromptRuns.delete(key); } } @@ -3736,9 +3813,10 @@ export class AgentsStore { prompt: string, attachments: PromptAttachment[], clientInputId: string, - imageMode: 'image' | 'resource' + imageMode: 'image' | 'resource', + ownsAction: () => boolean ) { - if (!this.isSelectedSession(agentId, sessionId)) return; + if (!ownsAction()) return; const key = buildSessionKey(agentId, sessionId); const pending: PendingSessionInput = { inputId: clientInputId, @@ -3746,7 +3824,7 @@ export class AgentsStore { state: 'sending', prompt, attachments, - runId: delivery === 'steer' ? runtime.active_run_id : undefined, + runId: delivery === 'steer' ? runtime.active_run_id ?? undefined : undefined, createdAt: Date.now() }; this.pendingInputsBySession = { @@ -3758,11 +3836,11 @@ export class AgentsStore { this.inputSubmissionsBySession.set(key, (this.inputSubmissionsBySession.get(key) ?? 0) + 1); try { const result = delivery === 'steer' - ? await client.steerSession(sessionId, prompt, attachments, clientInputId, runtime.active_run_id, imageMode) + ? await client.steerSession(sessionId, prompt, attachments, clientInputId, runtime.active_run_id ?? undefined, imageMode) : await client.queueSession(sessionId, prompt, attachments, clientInputId, imageMode); this.applySubmitInputResult(agentId, sessionId, pending, result); - if (!this.isSelectedSession(agentId, sessionId)) return; - if (result.status === 'started') { + if (!ownsAction()) return; + if (result.status === 'started' && !this.activeSessionRuntime?.observation) { this.activeSession.runState = 'thinking'; this.activeSession.activityLabel = 'Waiting for the agent to respond…'; beginSessionWork(this.activeSession); @@ -3776,14 +3854,15 @@ export class AgentsStore { this.inputNotificationsDuringSubmit.delete( sessionInputNotificationKey(agentId, sessionId, clientInputId) ); - this.updatePendingSessionInput(agentId, sessionId, clientInputId, { + const pendingInput = this.pendingInputsBySession[key]?.find((input) => input.inputId === clientInputId); + if (pendingInput?.state === 'sending') this.updatePendingSessionInput(agentId, sessionId, clientInputId, { state: 'failed', reason: error instanceof Error ? error.message : 'Input submission failed.' }); - if (this.isSelectedSession(agentId, sessionId)) { + if (ownsAction()) { this.error = error instanceof Error ? error.message : 'Failed to submit input.'; + await this.refreshSessionRuntime(agentId, sessionId, client).catch(() => null); } - await this.refreshSessionRuntime(agentId, sessionId, client).catch(() => null); } finally { const remainingSubmissions = (this.inputSubmissionsBySession.get(key) ?? 1) - 1; if (remainingSubmissions > 0) { @@ -3791,7 +3870,7 @@ export class AgentsStore { } else { this.inputSubmissionsBySession.delete(key); } - if (this.inputSubmitOwner === key) { + if (this.inputSubmitOwner === key && remainingSubmissions === 0) { this.inputSubmitOwner = null; this.inputSubmitPending = false; } @@ -3916,11 +3995,74 @@ export class AgentsStore { } } + private markSessionRuntimeUnavailable(agentId: string, sessionId: string) { + const current = this.sessionRuntimeBySession[buildSessionKey(agentId, sessionId)]; + if (current) this.reconcileSessionRuntime(agentId, sessionId, { + ...current, phase: 'unknown' as SessionRuntimeStatus['phase'], active_run_id: null, steerable: false, + observation: { ...current.observation, availability: 'unavailable' } + }, true); + } + + private reconcileManagedActivity(agentId: string, sessionId: string, updates: DelegationUpdateNotification[], complete = false, allowNewSource = false) { + const key = buildSessionKey(agentId, sessionId); + const merged = mergeDelegationActivity(this.sessionDelegations.get(key) ?? [], updates, complete, allowNewSource); + this.sessionDelegations.set(key, merged); + const runtime = this.sessionRuntimeBySession[key]; + if (runtime) this.sessionRuntimeBySession = { ...this.sessionRuntimeBySession, [key]: { + ...runtime, delegation_activity: merged.filter((item) => item.activity === 'live') + } }; + if (this.isSelectedSession(agentId, sessionId)) { + this.activeSession.delegations = merged; + projectDelegationActivity(this.activeSession); + } + } + + private reconcileSessionRuntime(agentId: string, sessionId: string, runtime: SessionRuntimeStatus, authoritative = false, completeActivity = false): boolean { + const key = buildSessionKey(agentId, sessionId); + const current = this.sessionRuntimeBySession[key]; + const before = current?.observation; + const after = runtime.observation; + // Managed workflows have their own sequence domain, including while self is idle. + const complete = completeActivity && runtime.phase !== 'unknown' && + (after?.availability === 'live' || after?.availability === 'inactive'); + if (runtime.delegation_activity) this.reconcileManagedActivity(agentId, sessionId, runtime.delegation_activity, complete, authoritative); + if (before?.epoch && after?.epoch) { + if (before.epoch !== after.epoch && !authoritative) return false; + if (before.epoch === after.epoch && before.revision !== undefined && + (after.revision === undefined || after.revision < before.revision || + (after.revision === before.revision && before.availability === after.availability))) return false; + } else if (before?.epoch && !authoritative) return false; + if (runtime.active_run_id && runtime.phase !== 'unknown' && runtime.observation?.availability !== 'unavailable') { + for (const pending of this.pendingPromptRuns.get(key) ?? []) pending.runId ??= runtime.active_run_id; + } + this.sessionRuntimeBySession = { ...this.sessionRuntimeBySession, [key]: { + ...runtime, delegation_activity: this.sessionDelegations.get(key)?.filter((item) => item.activity === 'live') ?? runtime.delegation_activity + } }; + this.projectSelectedExecution(agentId, sessionId, runtime); + const status = runtimeSessionStatus(runtime); + const patch = (session: DesktopSessionSummary) => session.agentId === agentId && session.sessionId === sessionId + ? { ...session, status } : session; + this.sessionsByAgent = { ...this.sessionsByAgent, [agentId]: (this.sessionsByAgent[agentId] ?? []).map(patch) }; + this.sessionCatalog = this.sessionCatalog.map(patch); + this.pinnedSessionsByAgent = { ...this.pinnedSessionsByAgent, [agentId]: (this.pinnedSessionsByAgent[agentId] ?? []).map(patch) }; + for (const source of Object.values(this.workspaceSessionSources[agentId] ?? {})) source.sessions = source.sessions.map(patch); + return true; + } + + private projectSelectedExecution(agentId: string, sessionId: string, runtime = this.sessionRuntimeBySession[buildSessionKey(agentId, sessionId)]) { + if (!runtime || !this.isSelectedSession(agentId, sessionId)) return; + const key = buildSessionKey(agentId, sessionId); + this.activeSession.delegations = this.sessionDelegations.get(key) ?? this.activeSession.delegations; + projectSessionRuntime(this.activeSession, runtime); + projectDelegationActivity(this.activeSession); + } + async refreshSessionRuntime( agentId = this.activeAgentId, sessionId = this.activeSessionId, client?: DesktopAcpClient, - fresh = false + fresh = false, + verificationEpoch?: string ): Promise { if (!agentId || !sessionId || isPendingLaunchId(sessionId) || this.pollingDisposed) return null; const record = client ? this.clients.get(agentId) : await this.connectInitializedRecord(agentId); @@ -3931,13 +4073,13 @@ export class AgentsStore { const selection = this.sessionSelectionGeneration; const selected = this.isSelectedSession(agentId, sessionId); const existing = this.sessionRuntimeRefreshPromises.get(key); - if (existing?.record === record) { + if (existing?.record === record && existing.generation === this.sessionRuntimeRefreshGenerations.get(key)) { if (!fresh && existing.selection === selection) return existing.promise; // Actions require a read started after any earlier polling request has settled. await existing.promise.catch(() => null); if (this.pollingDisposed || this.clients.get(agentId) !== record || selection !== this.sessionSelectionGeneration || (selected && !this.isSelectedSession(agentId, sessionId))) return null; - return this.refreshSessionRuntime(agentId, sessionId, resolvedClient, true); + return this.refreshSessionRuntime(agentId, sessionId, resolvedClient, true, verificationEpoch); } this.cancelSessionRuntimeRefresh(key); const generation = (this.sessionRuntimeRefreshGenerations.get(key) ?? 0) + 1; @@ -3945,17 +4087,38 @@ export class AgentsStore { const isCurrent = () => !this.pollingDisposed && this.clients.get(agentId) === record && selection === this.sessionSelectionGeneration && (!selected || this.isSelectedSession(agentId, sessionId)) && this.sessionRuntimeRefreshGenerations.get(key) === generation; + const initialRuntime = this.sessionRuntimeBySession[key]; + const initialEpoch = initialRuntime?.observation?.epoch; + // This nonreactive collection identity detects workflow updates during the read. + const initialActivity = this.sessionDelegations.get(key); const promise = resolvedClient.getSessionRuntimeStatus(sessionId).then((runtime) => { if (!isCurrent()) return null; - this.sessionRuntimeBySession = { ...this.sessionRuntimeBySession, [key]: runtime }; - if (runtime.phase !== 'idle') this.scheduleSessionRuntimeRefresh(agentId, sessionId, resolvedClient); + const current = this.sessionRuntimeBySession[key]; + if (current?.observation?.epoch !== initialEpoch && current?.observation?.epoch !== runtime.observation?.epoch) { + this.scheduleSessionRuntimeRefresh(agentId, sessionId, resolvedClient); + return current ?? null; + } + this.reconcileSessionRuntime(agentId, sessionId, runtime, true, this.sessionDelegations.get(key) === initialActivity); + const accepted = this.sessionRuntimeBySession[key] ?? runtime; + if (accepted.phase !== 'idle' || this.sessionDelegations.get(key)?.some((item) => item.activity === 'live')) this.scheduleSessionRuntimeRefresh(agentId, sessionId, resolvedClient); else this.cancelSessionRuntimeRefresh(key); - return runtime; + return accepted; }).catch((error) => { - if (isCurrent() && this.selectedRuntimeNeeded()) this.scheduleSessionRuntimeRefresh(agentId, sessionId, resolvedClient); + if (!isCurrent()) return null; + const latest = this.sessionRuntimeBySession[key]; + if (latest?.observation?.epoch !== initialEpoch || + latest?.observation?.revision !== initialRuntime?.observation?.revision || + latest?.observation?.availability !== initialRuntime?.observation?.availability || + latest?.phase !== initialRuntime?.phase || latest?.active_run_id !== initialRuntime?.active_run_id) return latest ?? null; + this.reconcileSessionRuntime(agentId, sessionId, { + phase: 'unknown' as SessionRuntimeStatus['phase'], steerable: false, + pending_steering_count: 0, queued_input_count: 0, + observation: { ...initialRuntime?.observation, availability: 'unavailable' } + }, true); + if (this.selectedRuntimeNeeded()) this.scheduleSessionRuntimeRefresh(agentId, sessionId, resolvedClient); throw error; }); - const flight = { record, selection, promise }; + const flight = { record, selection, generation, verificationEpoch, promise }; this.sessionRuntimeRefreshPromises.set(key, flight); try { return await promise; @@ -4038,26 +4201,38 @@ export class AgentsStore { } // Nothing to cancel until `session/new` has answered, and the first prompt has not left yet. if (isPendingLaunchId(this.activeSessionId) || this.isLaunchSettingUp(this.activeAgentId, this.activeSessionId)) return; - - if (!PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState) && this.activeSession.runState !== 'submitting') { - return; - } - + if (!this.activeRunBusy) return; + const agentId = this.activeAgentId; + const sessionId = this.activeSessionId; + const selection = this.sessionSelectionGeneration; + const runId = this.activeSessionRuntime?.active_run_id ?? this.activeSession.runId; + let owner: AgentClientRecord | undefined; + const ownsView = () => selection === this.sessionSelectionGeneration && this.isSelectedSession(agentId, sessionId) && + (!owner || this.clients.get(agentId) === owner); try { this.error = null; - this.activeSession.activityLabel = 'Cancelling turn…'; - this.activeSession.lastError = null; - const record = await this.connectInitializedRecord(this.activeAgentId); - if (!record) { - throw new Error('Failed to cancel ACP prompt.'); - } - await record.client.cancelSession(this.activeSessionId); + const record = await this.connectInitializedRecord(agentId); + if (!record) throw new Error('Failed to cancel ACP prompt.'); + owner = record; + if (!ownsView() || (runId && this.activeSessionRuntime?.active_run_id && this.activeSessionRuntime.active_run_id !== runId)) return; + this.activeSession.activityLabel = 'Cancelling turn...'; + await record.client.cancelSession(sessionId); + // ACP cancel acknowledges sending only. Reconcile self execution, not the tree or queue. + await this.refreshSessionRuntime(agentId, sessionId, record.client, true); } catch (error) { + if (!ownsView()) return; + const currentRunId = this.activeSessionRuntime?.active_run_id ?? this.activeSession.runId; + if (currentRunId && currentRunId !== runId) return; const message = error instanceof Error ? error.message : 'Failed to cancel ACP prompt.'; - this.activeSession.runState = 'failed'; - this.activeSession.lastError = message; - this.activeSession.activityLabel = message; this.error = message; + if (this.activeSessionRuntime && this.activeSessionRuntime.phase !== 'unknown' && + this.activeSessionRuntime.observation?.availability !== 'unavailable') { + this.projectSelectedExecution(agentId, sessionId); + return; + } + this.activeSession.lastError = message; + this.activeSession.runState = 'unknown'; + this.activeSession.activityLabel = 'Cancellation requested; execution status unavailable'; } } @@ -4079,8 +4254,9 @@ export class AgentsStore { } } const selectionGeneration = ++this.sessionSelectionGeneration; + let loadOwner: AgentClientRecord | undefined; const isCurrentLoad = () => selectionGeneration === this.sessionSelectionGeneration && - this.isSelectedSession(agentId, sessionId); + this.isSelectedSession(agentId, sessionId) && (!loadOwner || this.clients.get(agentId) === loadOwner); // A deep link may arrive before catalog discovery. Do not create a local // placeholder or scan every peer before the backend checks the bookmark. @@ -4093,6 +4269,7 @@ export class AgentsStore { this.error = 'Failed to connect to the agent.'; return; } + loadOwner = record; target ??= getSessionById(this.sessionsByAgent[agentId] ?? [], sessionId); if (!record.initializeResponse?.agentCapabilities?.loadSession) { @@ -4349,7 +4526,10 @@ export class AgentsStore { // the active mode's preference from it without issuing any config write // and without substituting a local model when metadata is missing. this.seedSessionModePreferenceFromLoad(agentId, sessionId); - if (snapshotLifecycle) { + const newerActivity = replay.some(({ update }) => update.sessionUpdate === 'user_message_chunk' || + ((update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') && + !snapshotSession.toolCalls.some((tool) => tool.id === update.toolCallId && (tool.status === 'completed' || tool.status === 'failed')))); + if (snapshotLifecycle && !newerActivity && drainedCount === 0) { this.activeSession.runState = snapshotLifecycle.runState; this.activeSession.runStateFromLifecycle = true; this.activeSession.activityLabel = snapshotLifecycle.activityLabel; @@ -4357,6 +4537,11 @@ export class AgentsStore { this.activeSession.lastError = snapshotLifecycle.lastError; } this.activeSession = normalizeHistoricalSession(this.activeSession, { loadCompleted: true }); + this.reconcileManagedActivity(agentId, sessionId, snapshotSession.delegations ?? []); + this.reconcileManagedActivity(agentId, sessionId, this.activeSession.delegations ?? []); + const snapshotRuntime = getSnapshotRuntimeStatus(loadedSession); + if (snapshotRuntime) this.reconcileSessionRuntime(agentId, sessionId, snapshotRuntime); + this.projectSelectedExecution(agentId, sessionId); this.rememberSessionChildren(agentId, sessionId, this.activeSession.toolCalls.map((tool) => tool.childSessionId)); checkpoint('frontend.normalize'); this.activeSession.undo.pendingOperation = pendingOperation; @@ -4374,7 +4559,7 @@ export class AgentsStore { telemetryStatus = 'cancelled'; return; } - if (!sessionHasVisibleHistory(this.activeSession) && drainedCount === 0) { + if (!sessionHasVisibleHistory(this.activeSession) && drainedCount === 0 && !PROMPT_ACTIVE_RUN_STATES.has(this.activeSession.runState) && this.activeSession.runState !== 'unknown') { this.activeSession.activityLabel = 'Session loaded, but the agent returned no replayable history.'; } this.hydrateLoadedSessionSummary(agentId, sessionId, replay); @@ -5032,6 +5217,20 @@ export class AgentsStore { this.connectFlights.delete(agentId); flight.abort(); } + for (const key of Object.keys(this.sessionRuntimeBySession)) { + if (key.startsWith(`${agentId}:`)) { + delete this.sessionRuntimeBySession[key]; + this.sessionDelegations.delete(key); + } + } + for (const key of this.sessionDelegations.keys()) if (key.startsWith(`${agentId}:`)) this.sessionDelegations.delete(key); + for (const key of this.pendingPromptRuns.keys()) if (key.startsWith(`${agentId}:`)) this.pendingPromptRuns.delete(key); + if (this.activeAgentId === agentId && this.activeSessionId) { + this.activeSession.delegations = this.activeSession.delegations?.map((item) => ({ ...item, activity: 'historical' })); + this.activeSession.dependencyLabel = null; + this.activeSession.runState = 'unknown'; + this.activeSession.activityLabel = 'Execution status unavailable'; + } record.unsubscribeSessionUpdates?.(); record.unsubscribeExtensionNotifications?.(); record.unsubscribeConnectionLoss?.(); @@ -5062,6 +5261,21 @@ export class AgentsStore { private ensureExtensionNotificationSubscription(agentId: string, record: AgentClientRecord) { if (!record.unsubscribeExtensionNotifications) { record.unsubscribeExtensionNotifications = record.client.onExtensionNotification((notification) => { + if (this.pollingDisposed || this.clients.get(agentId) !== record) return; + if (notification.method === QMT_NOTIFICATION_SESSION_RUNTIME_CHANGED) { + const params = notification.params as SessionRuntimeChangedNotification; + if (params?.version !== 1 || !params.session_id || !params.runtime?.phase) return; + const accepted = this.reconcileSessionRuntime(agentId, params.session_id, params.runtime); + const observedEpoch = this.sessionRuntimeBySession[buildSessionKey(agentId, params.session_id)]?.observation?.epoch; + if ((!accepted && params.runtime.observation?.epoch !== observedEpoch) || params.runtime.phase === 'unknown') { + const key = buildSessionKey(agentId, params.session_id); + this.sessionRuntimeRefreshGenerations.set(key, (this.sessionRuntimeRefreshGenerations.get(key) ?? 0) + 1); + if (this.isSelectedSession(agentId, params.session_id)) void this.refreshSessionRuntime(agentId, params.session_id, record.client, true).catch(() => null); + else this.markSessionRuntimeUnavailable(agentId, params.session_id); + } else if (params.runtime.phase !== 'idle' || (params.runtime.delegation_activity?.length ?? 0) > 0) { + this.scheduleSessionRuntimeRefresh(agentId, params.session_id, record.client); + } + } if (notification.method === 'querymt/models/changed') { void this.loadInitialModelsForAgent(agentId, 1); } @@ -5088,8 +5302,10 @@ export class AgentsStore { const sessionId = params.sessionId ?? params.session_id; if (sessionId) { this.rememberSessionChildren(agentId, sessionId, [params.childSessionId ?? params.child_session_id]); + if (params.delegationId) this.reconcileManagedActivity(agentId, sessionId, [params as DelegationUpdateNotification]); if (this.isSelectedSession(agentId, sessionId)) { this.activeSession = applyDelegationChildSession(this.activeSession, params); + this.projectSelectedExecution(agentId, sessionId); } } } @@ -5114,7 +5330,7 @@ export class AgentsStore { if (notification.method === 'querymt/pluginUpdateStatus') { this.pluginUpdateStatusByAgent = { ...this.pluginUpdateStatusByAgent, - [agentId]: notification.params as NonNullable + [agentId]: notification.params as NonNullable }; } if (notification.method === 'querymt/pluginUpdateComplete') { @@ -6346,7 +6562,8 @@ export class AgentsStore { const activityUpdatedAt = new Date().toISOString(); for (const { agentId, notification } of notifications) { const update = notification.update; - const status = inferSessionStatusFromNotification(update); + const runtime = this.sessionRuntimeBySession[buildSessionKey(agentId, notification.sessionId)]; + const status = runtime?.observation ? runtimeSessionStatus(runtime) : inferSessionStatusFromNotification(update); const summaryInfo = sessionInfoChangesSummary(update); if (!status && !summaryInfo) continue; let agentPatches = patches.get(agentId); @@ -6499,6 +6716,13 @@ export class AgentsStore { const optimisticEventIndex = this.reconcileOptimisticUserPrompt(notification); const beforeEvents = this.activeSession.events.length; this.activeSession = applySessionNotification(this.activeSession, notification, optimisticEventIndex); + const observed = this.sessionRuntimeBySession[buildSessionKey(agentId, notification.sessionId)]; + if (notification.update.sessionUpdate === 'tool_call' || notification.update.sessionUpdate === 'tool_call_update') { + const toolCallId = notification.update.toolCallId; + const tool = this.activeSession.toolCalls.find((item) => item.id === toolCallId); + if (tool && !tool.runId) tool.runId = observed?.active_run_id ?? this.activeSession.runId; + } + if (observed?.observation) this.projectSelectedExecution(agentId, notification.sessionId, observed); if (notification.update.sessionUpdate === 'config_option_update') { const key = buildSessionKey(agentId, notification.sessionId); // Authoritative updates may carry only the options relevant to the diff --git a/src/routes/sessions/[agentId]/[sessionId]/+page.svelte b/src/routes/sessions/[agentId]/[sessionId]/+page.svelte index 81e5bcb5..58f7c0e3 100644 --- a/src/routes/sessions/[agentId]/[sessionId]/+page.svelte +++ b/src/routes/sessions/[agentId]/[sessionId]/+page.svelte @@ -130,12 +130,7 @@ }); const composerCollapsed = $derived(chatPresentationState === 'fixed-free-compact'); const followPinClass = $derived(sessionFollowPinClass(scrollMode)); - const agentRunActive = $derived( - !agentsStore.sessionHistoryLoading && - ((agentsStore.activeSessionRuntime != null && - agentsStore.activeSessionRuntime.phase !== 'idle') || - ['submitting', 'thinking', 'streaming', 'tool-running'].includes(agentsStore.activeSession?.runState ?? 'idle')) - ); + const agentRunActive = $derived(agentsStore.activeRunBusy); // The agent name in the header only disambiguates between sessions when more // than one agent is actively connected. const activeAgentCount = $derived( From f12a811502c8cd56ae27dc61f733aa095339ca4f Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Thu, 29 Oct 2026 22:58:05 +0000 Subject: [PATCH 2/8] fix(ui): preserve live activity and clarify status hints --- .../session/SessionActivityBar.svelte | 60 +++++--- .../session/SessionActivityBar.test.ts | 53 ++++++- .../session/SessionDelegatesBubble.test.ts | 3 +- .../components/session/SessionHeader.svelte | 4 +- .../components/session/SessionHeader.test.ts | 12 ++ .../session/SessionQueuedBubble.test.ts | 3 +- src/lib/domain/sessions.test.ts | 21 +++ src/lib/domain/sessions.ts | 23 ++- src/lib/domain/types.ts | 2 + src/lib/stores/agents.svelte.test.ts | 138 ++++++++++++++++++ src/lib/stores/agents.svelte.ts | 36 +++-- 11 files changed, 304 insertions(+), 51 deletions(-) diff --git a/src/lib/components/session/SessionActivityBar.svelte b/src/lib/components/session/SessionActivityBar.svelte index aaa1718d..9382e7b8 100644 --- a/src/lib/components/session/SessionActivityBar.svelte +++ b/src/lib/components/session/SessionActivityBar.svelte @@ -1,5 +1,6 @@ {#if isVisible} -
- - {primaryStatus} - {#if session.runState === 'failed'} - Review the error, update the prompt, or refresh. - {:else if session.runState === 'waiting-input'} - Respond below to continue. - {:else if agentBusy && !session.undo.pendingOperation && !forkPending} - Double Esc to cancel - {/if} -
+ + {/if} diff --git a/src/lib/components/session/SessionActivityBar.test.ts b/src/lib/components/session/SessionActivityBar.test.ts index b563b3bb..79b9bbd6 100644 --- a/src/lib/components/session/SessionActivityBar.test.ts +++ b/src/lib/components/session/SessionActivityBar.test.ts @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/svelte'; +import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; import { afterEach, describe, expect, it } from 'vitest'; import SessionActivityBar from './SessionActivityBar.svelte'; import { session } from './session-fixture'; @@ -18,7 +18,56 @@ describe('SessionActivityBar', () => { render(SessionActivityBar, { session: session({ runState: 'thinking', activityLabel: 'Reviewing files…' }) }); expect(screen.getByRole('status')).toHaveTextContent('Reviewing files…'); - expect(screen.getByRole('status')).toHaveTextContent('Double Esc to cancel'); + expect(screen.getByRole('status')).not.toHaveTextContent('Double Esc to cancel'); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + }); + + it.each(['hover', 'focus'] as const)('shows the shared styled cancel tooltip on bar %s without a button or native title', async (intent) => { + render(SessionActivityBar, { session: session({ runState: 'tool-running' }) }); + const bar = screen.getByRole('status'); + expect(bar.tagName).toBe('SECTION'); + expect(bar).toHaveAttribute('aria-live', 'polite'); + expect(bar).toHaveAttribute('tabindex', '0'); + expect(bar).not.toHaveAttribute('title'); + expect(bar).not.toHaveTextContent('Double Esc to cancel'); + expect(screen.queryByText('Double Esc to cancel')).not.toBeInTheDocument(); + if (intent === 'hover') await fireEvent.pointerEnter(bar, { pointerType: 'mouse' }); + else bar.focus(); + const tooltip = await screen.findByText('Double Esc to cancel', { selector: '.app-tooltip-content' }); + expect(tooltip).toHaveAttribute('data-tooltip-content'); + expect(tooltip).toHaveClass('app-tooltip-content'); + expect(bar).toHaveAttribute('aria-describedby', tooltip.id); + expect(bar.contains(tooltip)).toBe(false); + expect(tooltip.closest('[data-bits-floating-content-wrapper]')?.parentElement).toBe(document.body); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + const escapes: KeyboardEvent[] = []; + const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') escapes.push(event); }; + window.addEventListener('keydown', onKeyDown); + try { + await fireEvent.keyDown(bar, { key: 'Escape' }); + await fireEvent.keyDown(bar, { key: 'Escape' }); + expect(escapes).toHaveLength(2); + // Tooltip dismissal prevents the first key's default but both keys still reach the app listener. + expect(escapes.map((event) => event.defaultPrevented)).toEqual([true, false]); + } finally { + window.removeEventListener('keydown', onKeyDown); + } + }); + + it.each(['dependency', 'undo', 'fork', 'unknown', 'failure', 'input'] as const)('suppresses cancel hint for %s while retaining guidance and live-region roles', async (mode) => { + const value = session({ runState: mode === 'unknown' ? 'unknown' : mode === 'failure' ? 'failed' : mode === 'input' ? 'waiting-input' : mode === 'dependency' ? 'completed' : 'thinking', + dependencyLabel: mode === 'dependency' ? 'Verifying delegate result' : null }); + if (mode === 'undo') value.undo.pendingOperation = 'undo'; + render(SessionActivityBar, { session: value, forkPending: mode === 'fork' }); + const bar = screen.getByRole(mode === 'failure' ? 'alert' : 'status'); + expect(bar).not.toHaveAttribute('title'); + expect(bar).not.toHaveAttribute('tabindex', '0'); + await fireEvent.pointerEnter(bar, { pointerType: 'mouse' }); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument(); + expect(screen.queryByText('Double Esc to cancel')).not.toBeInTheDocument(); + if (mode === 'failure') expect(bar).toHaveTextContent('Review the error, update the prompt, or refresh.'); + if (mode === 'input') expect(bar).toHaveTextContent('Respond below to continue.'); }); it('leaves stopping to the composer stop button instead of an inline cancel control', () => { diff --git a/src/lib/components/session/SessionDelegatesBubble.test.ts b/src/lib/components/session/SessionDelegatesBubble.test.ts index 8813d4be..90e77240 100644 --- a/src/lib/components/session/SessionDelegatesBubble.test.ts +++ b/src/lib/components/session/SessionDelegatesBubble.test.ts @@ -305,7 +305,8 @@ describe('SessionDelegatesBubble', () => { expect(screen.queryByRole('region', { name: 'Waiting messages' })).toBeNull(); expect(screen.getByRole('region', { name: 'Delegates in this turn' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '1 waiting message' })).toHaveTextContent(/^1$/); - expect(screen.getByRole('status')).toHaveTextContent('Double Esc to cancel'); + expect(screen.getByRole('status')).not.toHaveTextContent('Double Esc to cancel'); + expect(screen.getByRole('status')).toHaveAttribute('data-tooltip-trigger'); }); it('keeps both counts in the same activity row without making the composer own them', () => { diff --git a/src/lib/components/session/SessionHeader.svelte b/src/lib/components/session/SessionHeader.svelte index a794c699..6b58cada 100644 --- a/src/lib/components/session/SessionHeader.svelte +++ b/src/lib/components/session/SessionHeader.svelte @@ -69,11 +69,11 @@ if (forkPending) return { label: 'Creating fork', tone: 'running', busy: true }; if (session.undo.pendingOperation === 'undo') return { label: 'Undoing', tone: 'running', busy: true }; if (session.undo.pendingOperation === 'redo') return { label: 'Restoring', tone: 'running', busy: true }; - if (session.runState === 'unknown' || (!session.sessionId && summaryStatus === 'unknown')) return { label: 'Status unavailable', tone: 'warning', busy: false }; + if (session.runState === 'unknown' || (summaryStatus === 'unknown' && (!session.sessionId || ['idle', 'completed'].includes(session.runState)))) return { label: 'Status unavailable', tone: 'warning', busy: false }; + if (session.runState === 'failed') return { label: 'Failed', tone: 'danger', busy: false }; if (session.dependencyLabel && !['submitting', 'thinking', 'streaming', 'tool-running'].includes(session.runState)) { return { label: `Own turn idle - ${session.dependencyLabel}`, tone: 'running', busy: true }; } - if (session.runState === 'failed') return { label: 'Failed', tone: 'danger', busy: false }; if (session.runState === 'waiting-input' || (!session.sessionId && summaryStatus === 'waiting')) { return { label: 'Input needed', tone: 'warning', busy: false }; } diff --git a/src/lib/components/session/SessionHeader.test.ts b/src/lib/components/session/SessionHeader.test.ts index 01f5d50f..22bf31d1 100644 --- a/src/lib/components/session/SessionHeader.test.ts +++ b/src/lib/components/session/SessionHeader.test.ts @@ -18,6 +18,18 @@ const usageFixture = { afterEach(cleanup); describe('SessionHeader', () => { + it.each(['thinking', 'streaming', 'tool-running'] as const)('preserves known %s execution over a stale unknown summary', (runState) => { + render(SessionHeader, { session: session({ runState }), summaryStatus: 'unknown', title: 'Active', workspace: 'work', updatedAt: 'Now' }); + expect(screen.getByLabelText('Status: Working')).toBeInTheDocument(); + expect(screen.queryByLabelText('Status: Status unavailable')).not.toBeInTheDocument(); + }); + + it('keeps observed failed execution visible while a dependency is live', () => { + render(SessionHeader, { session: session({ runState: 'failed', dependencyLabel: 'Verifying delegate result' }), + title: 'Failed', workspace: 'work', updatedAt: 'Now' }); + expect(screen.getByLabelText('Status: Failed')).toBeInTheDocument(); + }); + it.each([false, true])('offers pin/unpin only inside the ellipsis menu (pinned=%s)', async (pinned) => { const onSetPinned = vi.fn(async () => {}); const onSetLifecycle = vi.fn(async () => {}); diff --git a/src/lib/components/session/SessionQueuedBubble.test.ts b/src/lib/components/session/SessionQueuedBubble.test.ts index 7bf12908..5d5344cb 100644 --- a/src/lib/components/session/SessionQueuedBubble.test.ts +++ b/src/lib/components/session/SessionQueuedBubble.test.ts @@ -206,7 +206,8 @@ describe('SessionQueuedBubble', () => { const activity = screen.getByRole('status'); expect(container.firstElementChild).toBe(bubble); expect(bubble.nextElementSibling).toBe(activity); - expect(activity).toHaveTextContent('Double Esc to cancel'); + expect(activity).not.toHaveTextContent('Double Esc to cancel'); + expect(activity).toHaveAttribute('data-tooltip-trigger'); await fireEvent.click(bubble); expect(screen.getByRole('region', { name: 'Waiting messages' }).parentElement).toBe(container); diff --git a/src/lib/domain/sessions.test.ts b/src/lib/domain/sessions.test.ts index b6dcf44f..17cb99e7 100644 --- a/src/lib/domain/sessions.test.ts +++ b/src/lib/domain/sessions.test.ts @@ -279,6 +279,27 @@ describe('workspace location', () => { }); describe('inferSessionStatus', () => { + it.each([ + ['tools', 'thinking'], ['waiting', 'thinking'], ['cancel_requested', 'cancelling'], + ['idle', 'idle'], ['unknown', 'unknown'] + ] as const)('accepts remote runtime-only %s independently of absent statistics', (phase, status) => { + const lifecycle = { session_id: 'session-1', resolved: false, archived: false, resolved_at: null, archived_at: null }; + const listed = createSession({ + location: 'remote', nodeId: 'node-1', nodeLabel: 'Owner', profileId: 'coder', profileLabel: 'Coder', + connectionState: 'available', parentSessionId: 'parent', forkOrigin: 'delegation', sessionKind: 'delegate', + querymt: { lifecycle }, runtimeStatus: { ...runtimeMeta(phase), observation: { availability: phase === 'unknown' ? 'unavailable' : 'live' } } + }); + expect(inferSessionStatus(listed)).toBe(status); + expect(mapAcpSessionsToDesktopSessions([listed], { agentId: 'agent-1', agentName: 'Agent' })[0]).toMatchObject({ + status, location: 'remote', remoteNodeId: 'node-1', remoteNodeLabel: 'Owner', + remoteProfileId: 'coder', remoteProfileLabel: 'Coder', remoteConnectionState: 'available', lifecycle, + parentSessionId: 'parent', forkOrigin: 'delegation', sessionKind: 'delegate', relationshipKnown: true + }); + expect(listed._meta).not.toHaveProperty('messageCount'); + expect(listed._meta).not.toHaveProperty('userMessageCount'); + expect(listed._meta).not.toHaveProperty('hasErrors'); + }); + it.each(['model', 'tools', 'closing'] as const)('maps active %s runtime to thinking, including closing', (phase) => { expect( inferSessionStatus( diff --git a/src/lib/domain/sessions.ts b/src/lib/domain/sessions.ts index 4090d99e..398e37a1 100644 --- a/src/lib/domain/sessions.ts +++ b/src/lib/domain/sessions.ts @@ -145,6 +145,7 @@ export function mapAcpSessionsToDesktopSessions( remoteConnectionState: session._meta?.connectionState === 'disconnected' ? 'disconnected' : session._meta?.connectionState === 'available' ? 'available' : undefined, status: inferSessionStatus(session), + hasUserHistory: readOperationalSessionMeta(session)?.hasUserHistory, lifecycle: readSessionLifecycle(session._meta?.querymt, session.sessionId), pin: readSessionPin(session._meta?.querymt, session.sessionId), // Disconnected bookmark fallbacks do not carry owner-sourced parentage. @@ -224,10 +225,10 @@ export function inferSessionStatus(session: SessionInfo): SessionStatus { return 'unknown'; } - return runtimeSessionStatus(meta.runtimeStatus, meta.userMessageCount > 0); + return runtimeSessionStatus(meta.runtimeStatus, meta.hasUserHistory); } -export function runtimeSessionStatus(runtime: QuerymtSessionMeta['runtimeStatus'], hasHistory = true): SessionStatus { +export function runtimeSessionStatus(runtime: QuerymtSessionMeta['runtimeStatus'], hasHistory = false): SessionStatus { if (runtime.observation?.availability === 'unavailable') return 'unknown'; switch (runtime.phase) { case QuerymtSessionRuntimePhase.Starting: @@ -245,23 +246,21 @@ export function runtimeSessionStatus(runtime: QuerymtSessionMeta['runtimeStatus' } } -function readOperationalSessionMeta(session: SessionInfo): QuerymtSessionMeta | null { +function readOperationalSessionMeta(session: SessionInfo): { runtimeStatus: QuerymtSessionMeta['runtimeStatus']; hasUserHistory?: boolean } | null { const meta = session._meta; if (!meta || typeof meta !== 'object') { return null; } const candidate = meta as Partial; - if ( - typeof candidate.messageCount !== 'number' || - typeof candidate.userMessageCount !== 'number' || - typeof candidate.hasErrors !== 'boolean' || - typeof candidate.runtimeStatus?.phase !== 'string' - ) { - return null; - } + if (typeof candidate.runtimeStatus?.phase !== 'string') return null; - return candidate as QuerymtSessionMeta; + // Remote rows can have authoritative runtime without owner history statistics. + const count = candidate.userMessageCount; + return { + runtimeStatus: candidate.runtimeStatus, + hasUserHistory: typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count > 0 : undefined + }; } export function readSessionRelationshipMeta(session: Pick): SessionRelationshipMeta { diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts index 97ef8c94..610c34c8 100644 --- a/src/lib/domain/types.ts +++ b/src/lib/domain/types.ts @@ -142,6 +142,8 @@ export interface DesktopSessionSummary { remoteProfileLabel?: string; remoteConnectionState?: 'available' | 'disconnected'; status: SessionStatus; + /** Undefined when the owner has not supplied user-history evidence. */ + hasUserHistory?: boolean; lifecycle?: SessionLifecycle | null; pin?: SessionPin | null; /** False when load/bookmark metadata does not establish parentage. */ diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts index 5378fa8f..f57245ff 100644 --- a/src/lib/stores/agents.svelte.test.ts +++ b/src/lib/stores/agents.svelte.test.ts @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SessionActionsMenu from '$lib/components/primitives/SessionActionsMenu.svelte'; import SessionActivityBar from '$lib/components/session/SessionActivityBar.svelte'; +import SessionHeader from '$lib/components/session/SessionHeader.svelte'; import { RequestError, type InitializeResponse, type ListSessionsRequest, type PromptResponse, type SessionConfigOption, type SessionInfo, type SessionNotification, type SetSessionConfigOptionRequest } from '@agentclientprotocol/sdk'; import type { AgentConfig, DesktopSessionSummary, ModelEntry, PromptAttachment, PromptSendOptions } from '$lib/domain/types'; import { findModeConfigOption, findReasoningConfigOption, getModelSelectionKey } from '$lib/querymt/config-options'; @@ -5870,6 +5871,143 @@ describe('AgentsStore mesh node availability', () => { describe('AgentsStore status consistency', () => { afterEach(cleanup); + it('retains positive user-history evidence accumulated within one summary batch', async () => { + const store = await selectedStore(); + const listed = (revision: number): SessionInfo => ({ sessionId: 'session-1', cwd: '/work', _meta: { + messageCount: 0, userMessageCount: 0, hasErrors: false, runtimeStatus: runtime(SessionRuntimePhase.Idle, revision) + } }); + mockClient.listSessions.mockResolvedValueOnce({ sessions: [listed(1)] }); + await store.refreshSessionsForAgent('agent-1'); + expect(store.sessionsByAgent['agent-1'][0].hasUserHistory).toBe(false); + const notifications: SessionNotification[] = [ + { sessionId: 'session-1', update: { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'New turn' } } }, + { sessionId: 'session-1', update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Working' } } }, + { sessionId: 'session-1', update: { sessionUpdate: 'tool_call', toolCallId: 'new-shell', title: 'Shell', status: 'in_progress' } } + ]; + (store as unknown as { applySessionSummaryUpdates(updates: Array<{ agentId: string; notification: SessionNotification }>): void }) + .applySessionSummaryUpdates(notifications.map((notification) => ({ agentId: 'agent-1', notification }))); + expect(store.sessionsByAgent['agent-1'][0].hasUserHistory).toBe(true); + announce(runtime(SessionRuntimePhase.Idle, 2)); + expect(store.sessionsByAgent['agent-1'][0].status).toBe('completed'); + mockClient.listSessions.mockResolvedValueOnce({ sessions: [listed(3)] }); + await store.refreshSessionsForAgent('agent-1'); + expect(store.sessionsByAgent['agent-1'][0]).toMatchObject({ status: 'idle', hasUserHistory: false }); + }); + + it.each(['tool_call', 'tool_call_update'] as const)('keeps new B %s progress and Stop when cached Idle A predates it', async (sessionUpdate) => { + const store = await selectedStore(); + announce(runtime(SessionRuntimePhase.Tools, 1, { active_run_id: 'run-a' })); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'tool_call', toolCallId: 'shell-a', title: 'Shell A', status: 'in_progress' + } }); + announce(runtime(SessionRuntimePhase.Idle, 2)); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate, toolCallId: 'shell-b', title: 'Long shell B', status: 'in_progress' + } }); + const toolB = () => store.activeSession.toolCalls.find((tool) => tool.id === 'shell-b'); + expect(toolB()).toMatchObject({ status: 'in_progress' }); + expect(toolB()?.runId).not.toBe('run-a'); + expect(store.activeRunBusy).toBe(true); + expect(store.activeSession.activeToolCallId).toBe('shell-b'); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'usage_update', used: 4, size: 100 + } }); + expect(toolB()?.status).toBe('in_progress'); + expect(store.activeRunBusy).toBe(true); + announce(runtime(SessionRuntimePhase.Tools, 3, { active_run_id: 'run-b' })); + expect(toolB()).toMatchObject({ runId: 'run-b', status: 'in_progress' }); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'tool_call_update', toolCallId: 'shell-a', status: 'completed', rawOutput: 'Late real A result' + } }); + expect(toolB()).toMatchObject({ runId: 'run-b', status: 'in_progress' }); + expect(store.activeSession).toMatchObject({ runId: 'run-b', activeToolCallId: 'shell-b' }); + expect(store.activeRunBusy).toBe(true); + }); + + it.each(['unsupported', 'unavailable'] as const)('keeps actual loaded history unavailable in the header when execution is %s', async (mode) => { + const store = createStore(); + mockClient.supportsQuerymtMethod.mockReturnValue(mode !== 'unsupported'); + mockClient.listSessions.mockResolvedValue({ sessions: [{ sessionId: 'session-1', cwd: '/tmp/work' }] }); + await store.connectAgent('agent-1', true); + await store.refreshSessionsForAgent('agent-1'); + mockClient.loadSession.mockResolvedValueOnce({ response: { configOptions: [] }, replay: [{ sessionId: 'session-1', update: { + sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Stored answer' } + } }] }); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Unknown, 0, { + observation: { availability: 'unavailable' } + })); + await store.loadSession('agent-1', 'session-1'); + const summary = store.sessionsByAgent['agent-1'].find((item) => item.sessionId === 'session-1'); + expect(summary?.status).toBe('unknown'); + render(SessionHeader, { session: store.activeSession, summaryStatus: summary!.status, + title: 'History', workspace: 'work', updatedAt: 'Now' }); + expect(screen.getByLabelText('Status: Status unavailable')).toBeInTheDocument(); + expect(screen.queryByLabelText('Status: Turn complete')).not.toBeInTheDocument(); + if (mode === 'unsupported') expect(mockClient.getSessionRuntimeStatus).not.toHaveBeenCalled(); + }); + + it('keeps an actual failed own outcome visible with a live verifier without exposing own Stop', async () => { + const store = await selectedStore(); + mockClient.loadSession.mockResolvedValueOnce({ response: { configOptions: [], _meta: { + 'querymt/sessionLoadSnapshot.v1': { audit: { events: [ + { seq: 1, kind: { type: 'run_started', data: { run_id: 'run-a' } } }, + { seq: 2, kind: { type: 'run_completed', data: { run_id: 'run-a', outcome: 'failed' } } } + ] } } + } }, replay: [] }); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 2, { delegation_activity: [verifier] })); + await store.loadSession('agent-1', 'session-1'); + expect(store.activeSession.runState).toBe('failed'); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + expect(store.activeRunBusy).toBe(false); + render(SessionHeader, { session: store.activeSession, title: 'Failed own turn', workspace: 'work', updatedAt: 'Now' }); + expect(screen.getByLabelText('Status: Failed')).toBeInTheDocument(); + expect(mockClient.cancelSession).not.toHaveBeenCalled(); + }); + + it.each([ + [SessionRuntimePhase.Tools, 'thinking'], [SessionRuntimePhase.Waiting, 'thinking'], + [SessionRuntimePhase.CancelRequested, 'cancelling'], [SessionRuntimePhase.Idle, 'idle'], + [SessionRuntimePhase.Unknown, 'unknown'] + ] as const)('accepts remote runtime-only %s without inventing history or dropping owner metadata', async (phase, status) => { + const store = await selectedStore(); + const runtimeStatus = runtime(phase, 1, { observation: { availability: phase === SessionRuntimePhase.Unknown ? 'unavailable' : 'live', epoch: 'remote-e1', revision: 1 } }); + const lifecycle = { session_id: 'remote-s', resolved: false, archived: false, resolved_at: null, archived_at: null }; + mockClient.listSessions.mockResolvedValueOnce({ sessions: [{ sessionId: 'remote-s', cwd: '/remote/work', _meta: { + location: 'remote', nodeId: 'remote-node', nodeLabel: 'Owner', profileId: 'coder', profileLabel: 'Coder', + connectionState: 'available', parentSessionId: 'parent', forkOrigin: 'delegation', sessionKind: 'delegate', + querymt: { lifecycle }, runtimeStatus + } }] }); + await store.refreshSessionsForAgent('agent-1'); + expect(store.sessionsByAgent['agent-1'].find((item) => item.sessionId === 'remote-s')).toMatchObject({ + status, location: 'remote', remoteNodeId: 'remote-node', remoteNodeLabel: 'Owner', + remoteProfileId: 'coder', remoteProfileLabel: 'Coder', remoteConnectionState: 'available', + lifecycle, parentSessionId: 'parent', forkOrigin: 'delegation', sessionKind: 'delegate', relationshipKnown: true + }); + }); + + it('preserves zero, known and unavailable history evidence through list/runtime/notification summary paths', async () => { + const store = await selectedStore(); + mockClient.listSessions.mockResolvedValueOnce({ sessions: [ + { sessionId: 'empty', cwd: '/work', _meta: { messageCount: 0, userMessageCount: 0, hasErrors: false, runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) } }, + { sessionId: 'used', cwd: '/work', _meta: { messageCount: 2, userMessageCount: 1, hasErrors: false, runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) } }, + { sessionId: 'runtime-only', cwd: '/remote', _meta: { runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) } } + ] }); + await store.refreshSessionsForAgent('agent-1'); + const check = () => expect(Object.fromEntries(store.sessionsByAgent['agent-1'].filter((item) => ['empty', 'used', 'runtime-only'].includes(item.sessionId)) + .map((item) => [item.sessionId, item.status]))).toEqual({ empty: 'idle', used: 'completed', 'runtime-only': 'idle' }); + check(); + for (const sessionId of ['empty', 'used', 'runtime-only']) { + mockClient.emitExtensionNotification({ method: 'querymt/session/runtimeChanged', params: { + version: 1, session_id: sessionId, runtime: runtime(SessionRuntimePhase.Tools, 2) + } }); + mockClient.emitExtensionNotification({ method: 'querymt/session/runtimeChanged', params: { + version: 1, session_id: sessionId, runtime: runtime(SessionRuntimePhase.Idle, 3) + } }); + mockClient.emitSessionUpdate({ sessionId, update: { sessionUpdate: 'config_option_update', configOptions: [] } }); + } + check(); + }); + it.each([SessionRuntimePhase.Tools, SessionRuntimePhase.Idle])('F1: rejects stale runtime errors after newer healthy %s', async (phase) => { const store = await selectedStore(); announce(runtime(SessionRuntimePhase.Model, 5, { active_run_id: 'run-a' })); diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts index e0598855..a80e2867 100644 --- a/src/lib/stores/agents.svelte.ts +++ b/src/lib/stores/agents.svelte.ts @@ -644,7 +644,8 @@ export class AgentsStore { } const incoming = mapAcpSessionsToDesktopSessions(sessions, agent).map((summary) => { const runtime = this.sessionRuntimeBySession[buildSessionKey(agent.agentId, summary.sessionId)]; - return runtime?.observation ? { ...summary, status: runtimeSessionStatus(runtime) } : summary; + const hasUserHistory = summary.hasUserHistory ?? getSessionById(this.sessionsByAgent[agent.agentId] ?? [], summary.sessionId)?.hasUserHistory; + return runtime?.observation ? { ...summary, hasUserHistory, status: runtimeSessionStatus(runtime, hasUserHistory) } : summary; }); const incomingKeys = new Set(incoming.map(getSessionKey)); const summaries = mergeSessions(this.sessionsByAgent[agent.agentId] ?? [], incoming) @@ -4039,9 +4040,8 @@ export class AgentsStore { ...runtime, delegation_activity: this.sessionDelegations.get(key)?.filter((item) => item.activity === 'live') ?? runtime.delegation_activity } }; this.projectSelectedExecution(agentId, sessionId, runtime); - const status = runtimeSessionStatus(runtime); const patch = (session: DesktopSessionSummary) => session.agentId === agentId && session.sessionId === sessionId - ? { ...session, status } : session; + ? { ...session, status: runtimeSessionStatus(runtime, session.hasUserHistory) } : session; this.sessionsByAgent = { ...this.sessionsByAgent, [agentId]: (this.sessionsByAgent[agentId] ?? []).map(patch) }; this.sessionCatalog = this.sessionCatalog.map(patch); this.pinnedSessionsByAgent = { ...this.pinnedSessionsByAgent, [agentId]: (this.pinnedSessionsByAgent[agentId] ?? []).map(patch) }; @@ -4053,6 +4053,11 @@ export class AgentsStore { if (!runtime || !this.isSelectedSession(agentId, sessionId)) return; const key = buildSessionKey(agentId, sessionId); this.activeSession.delegations = this.sessionDelegations.get(key) ?? this.activeSession.delegations; + if (runtime.active_run_id && runtime.phase !== 'idle' && runtime.phase !== 'unknown' && runtime.observation?.availability !== 'unavailable') { + for (const tool of this.activeSession.toolCalls) { + if (tool.runId === null && (tool.status === 'pending' || tool.status === 'in_progress')) tool.runId = runtime.active_run_id; + } + } projectSessionRuntime(this.activeSession, runtime); projectDelegationActivity(this.activeSession); } @@ -6563,12 +6568,15 @@ export class AgentsStore { for (const { agentId, notification } of notifications) { const update = notification.update; const runtime = this.sessionRuntimeBySession[buildSessionKey(agentId, notification.sessionId)]; - const status = runtime?.observation ? runtimeSessionStatus(runtime) : inferSessionStatusFromNotification(update); const summaryInfo = sessionInfoChangesSummary(update); - if (!status && !summaryInfo) continue; let agentPatches = patches.get(agentId); if (!agentPatches) patches.set(agentId, agentPatches = new Map()); const patch = agentPatches.get(notification.sessionId) ?? {}; + const hasUserHistory = update.sessionUpdate === 'user_message_chunk' ? true + : patch.hasUserHistory ?? getSessionById(this.sessionsByAgent[agentId] ?? [], notification.sessionId)?.hasUserHistory; + const status = runtime?.observation ? runtimeSessionStatus(runtime, hasUserHistory) : inferSessionStatusFromNotification(update); + if (!status && !summaryInfo) continue; + if (hasUserHistory !== undefined) patch.hasUserHistory = hasUserHistory; if (status) { patch.status = status; patch.updatedAt = newestTimestamp(patch.updatedAt, activityUpdatedAt); @@ -6588,18 +6596,18 @@ export class AgentsStore { const patch = agentPatches.get(session.sessionId); if (!patch) return session; const next = { ...session, ...patch, updatedAt: newestTimestamp(session.updatedAt, patch.updatedAt) }; - if (next.title === session.title && next.status === session.status && next.updatedAt === session.updatedAt) return session; + if (next.title === session.title && next.status === session.status && next.updatedAt === session.updatedAt && next.hasUserHistory === session.hasUserHistory) return session; // Streaming activity bumps updatedAt on every notification; rebuilding every derived // session list that often is the dominant cost with several busy sessions. Coalesce // activity-only bumps to at most once per ACTIVITY_SUMMARY_THROTTLE_MS per session. - if (next.title === session.title && next.status === session.status && patch.updatedAt === activityUpdatedAt) { + if (next.title === session.title && next.status === session.status && next.hasUserHistory === session.hasUserHistory && patch.updatedAt === activityUpdatedAt) { const previous = Date.parse(session.updatedAt ?? ''); if (Number.isFinite(previous) && Math.abs(activityNow - previous) < ACTIVITY_SUMMARY_THROTTLE_MS) return session; } const key = getSessionKey(next); updated.set(key, next); const write = this.catalogWrites.get(key); - if (write?.session) write.session = { ...write.session, title: next.title, updatedAt: next.updatedAt, status: next.status }; + if (write?.session) write.session = { ...write.session, title: next.title, updatedAt: next.updatedAt, status: next.status, hasUserHistory: next.hasUserHistory }; changes.push(next); return next; }); @@ -6610,7 +6618,7 @@ export class AgentsStore { if (!updated.size) return; const updateKnown = (session: DesktopSessionSummary) => { const next = updated.get(getSessionKey(session)); - return next ? { ...session, title: next.title, updatedAt: next.updatedAt, status: next.status } : session; + return next ? { ...session, title: next.title, updatedAt: next.updatedAt, status: next.status, hasUserHistory: next.hasUserHistory } : session; }; this.sessionCatalog = this.sessionCatalog.map(updateKnown); if (notifications.some(({ notification }) => sessionInfoChangesSummary(notification.update))) { @@ -6720,9 +6728,14 @@ export class AgentsStore { if (notification.update.sessionUpdate === 'tool_call' || notification.update.sessionUpdate === 'tool_call_update') { const toolCallId = notification.update.toolCallId; const tool = this.activeSession.toolCalls.find((item) => item.id === toolCallId); - if (tool && !tool.runId) tool.runId = observed?.active_run_id ?? this.activeSession.runId; + // New live tools after idle have no observed owner yet; never inherit the ended run. + if (tool && tool.runId === undefined) tool.runId = observed?.phase === 'idle' ? null : observed?.active_run_id ?? null; + } + const unobservedTool = this.activeSession.toolCalls.some((tool) => + tool.runId === null && (tool.status === 'pending' || tool.status === 'in_progress')); + if (observed?.observation && !(observed.phase === 'idle' && unobservedTool)) { + this.projectSelectedExecution(agentId, notification.sessionId, observed); } - if (observed?.observation) this.projectSelectedExecution(agentId, notification.sessionId, observed); if (notification.update.sessionUpdate === 'config_option_update') { const key = buildSessionKey(agentId, notification.sessionId); // Authoritative updates may carry only the options relevant to the @@ -7281,6 +7294,7 @@ function mergeSessions(current: DesktopSessionSummary[], incoming: DesktopSessio // A list that lacks worktree metadata must not erase what a richer list/load established. workspace: session.workspace ?? existing.workspace ?? null, access: session.access ?? existing.access ?? null, + hasUserHistory: session.hasUserHistory ?? existing.hasUserHistory, relationshipKnown: existing.relationshipKnown === true ? true : session.relationshipKnown ?? existing.relationshipKnown, updatedAt: newestTimestamp(existing.updatedAt, session.updatedAt) } : session); From 938fa4498c05c00825b6d4e7d75e19a2aa8a757f Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Thu, 29 Oct 2026 23:13:47 +0000 Subject: [PATCH 3/8] fix(ui): preserve recency for metadata-only updates --- src/lib/stores/agents.svelte.test.ts | 59 ++++++++++++++++++++++++++++ src/lib/stores/agents.svelte.ts | 5 ++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts index f57245ff..7c3e55cf 100644 --- a/src/lib/stores/agents.svelte.test.ts +++ b/src/lib/stores/agents.svelte.test.ts @@ -5871,6 +5871,65 @@ describe('AgentsStore mesh node availability', () => { describe('AgentsStore status consistency', () => { afterEach(cleanup); + it.each([ + { sessionUpdate: 'config_option_update', configOptions: [] }, + { sessionUpdate: 'current_mode_update', currentModeId: 'build' }, + { sessionUpdate: 'usage_update', used: 4, size: 100 }, + { sessionUpdate: 'available_commands_update', availableCommands: [] } + ] satisfies SessionNotification['update'][])('keeps observed-idle recency/order for $sessionUpdate while reconciling status', async (update) => { + const store = await selectedStore(); + const oldStamp = '2020-01-01T00:00:00Z'; + const otherStamp = '2021-01-01T00:00:00Z'; + mockClient.listSessions.mockResolvedValueOnce({ sessions: [ + { sessionId: 'session-1', title: 'Idle S', cwd: '/work', updatedAt: oldStamp, _meta: { + userMessageCount: 0, runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) + } }, + { sessionId: 'other-session', title: 'Newer T', cwd: '/work', updatedAt: otherStamp, _meta: { + userMessageCount: 0, runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) + } } + ] }); + await store.refreshSessionsForAgent('agent-1'); + // A stale summary must still converge to cached runtime without inventing activity. + store.sessionsByAgent = { ...store.sessionsByAgent, 'agent-1': store.sessionsByAgent['agent-1'].map((item) => + item.sessionId === 'session-1' ? { ...item, status: 'unknown' as const } : item) }; + mockClient.emitSessionUpdate({ sessionId: 'session-1', update }); + const own = () => store.sessionsByAgent['agent-1'].find((item) => item.sessionId === 'session-1'); + expect(own()).toMatchObject({ status: 'idle', updatedAt: oldStamp, hasUserHistory: false }); + expect(store.sessions.map((item) => item.sessionId)).toEqual(['other-session', 'session-1']); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'session_info_update', title: 'Renamed S' + } }); + expect(own()).toMatchObject({ title: 'Renamed S', updatedAt: oldStamp }); + const explicitStamp = '2022-01-01T00:00:00Z'; + mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'session_info_update', updatedAt: explicitStamp + } }); + expect(own()).toMatchObject({ title: 'Renamed S', updatedAt: explicitStamp, status: 'idle' }); + expect(store.sessions.map((item) => item.sessionId)).toEqual(['session-1', 'other-session']); + }); + + it.each([ + { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'New turn' } }, + { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Working' } }, + { sessionUpdate: 'tool_call', toolCallId: 'shell-new', title: 'Shell', status: 'in_progress' } + ] satisfies SessionNotification['update'][])('still bumps genuine $sessionUpdate activity with observed runtime', async (update) => { + const store = await selectedStore(); + const oldStamp = '2020-01-01T00:00:00Z'; + mockClient.listSessions.mockResolvedValueOnce({ sessions: [ + { sessionId: 'session-1', cwd: '/work', updatedAt: oldStamp, _meta: { + userMessageCount: 0, runtimeStatus: runtime(SessionRuntimePhase.Idle, 1) + } }, + { sessionId: 'other-session', cwd: '/work', updatedAt: '2021-01-01T00:00:00Z' } + ] }); + await store.refreshSessionsForAgent('agent-1'); + mockClient.emitSessionUpdate({ sessionId: 'session-1', update }); + const own = store.sessionsByAgent['agent-1'].find((item) => item.sessionId === 'session-1')!; + expect(own.updatedAt! > '2021-01-01T00:00:00Z').toBe(true); + expect(store.sessions.map((item) => item.sessionId)).toEqual(['session-1', 'other-session']); + expect(own.hasUserHistory).toBe(update.sessionUpdate === 'user_message_chunk'); + expect(own.status).toBe(update.sessionUpdate === 'user_message_chunk' ? 'completed' : 'idle'); + }); + it('retains positive user-history evidence accumulated within one summary batch', async () => { const store = await selectedStore(); const listed = (revision: number): SessionInfo => ({ sessionId: 'session-1', cwd: '/work', _meta: { diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts index a80e2867..83ace8ba 100644 --- a/src/lib/stores/agents.svelte.ts +++ b/src/lib/stores/agents.svelte.ts @@ -6574,12 +6574,13 @@ export class AgentsStore { const patch = agentPatches.get(notification.sessionId) ?? {}; const hasUserHistory = update.sessionUpdate === 'user_message_chunk' ? true : patch.hasUserHistory ?? getSessionById(this.sessionsByAgent[agentId] ?? [], notification.sessionId)?.hasUserHistory; - const status = runtime?.observation ? runtimeSessionStatus(runtime, hasUserHistory) : inferSessionStatusFromNotification(update); + const activityStatus = inferSessionStatusFromNotification(update); + const status = runtime?.observation ? runtimeSessionStatus(runtime, hasUserHistory) : activityStatus; if (!status && !summaryInfo) continue; if (hasUserHistory !== undefined) patch.hasUserHistory = hasUserHistory; if (status) { patch.status = status; - patch.updatedAt = newestTimestamp(patch.updatedAt, activityUpdatedAt); + if (activityStatus) patch.updatedAt = newestTimestamp(patch.updatedAt, activityUpdatedAt); } if (summaryInfo && update.sessionUpdate === 'session_info_update') { if (update.title !== undefined) patch.title = update.title ?? 'Untitled session'; From ab824885842b1224f7903c1470247d2eb48944ef Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Fri, 30 Oct 2026 02:34:29 +0000 Subject: [PATCH 4/8] fix(ui): reconcile explicit remote attachment state --- src/lib/stores/agents.svelte.test.ts | 212 +++++++++++++++++++++++++++ src/lib/stores/agents.svelte.ts | 115 ++++++++++----- 2 files changed, 292 insertions(+), 35 deletions(-) diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts index 7c3e55cf..f59f0057 100644 --- a/src/lib/stores/agents.svelte.test.ts +++ b/src/lib/stores/agents.svelte.test.ts @@ -500,6 +500,218 @@ afterEach(() => { vi.useRealTimers(); }); +describe('AgentsStore explicit remote attach runtime consistency', () => { + const id = 'remote-session-1'; + const key = `agent-1:${id}`; + const verifier = { version: 1, sessionId: id, delegationId: 'verify-1', targetAgentId: 'coder', + state: DelegationUpdateState.Forked, objective: 'Check result', requestedAt: 1, updatedAt: 2, + activity: 'live', stage: 'verifying', sourceEpoch: 'workflow-1', sessionSeq: 17 } as const; + const runtime = (phase = SessionRuntimePhase.Tools, revision = 4, extra: Partial = {}): SessionRuntimeStatus => ({ + phase, active_run_id: phase === SessionRuntimePhase.Idle || phase === SessionRuntimePhase.Unknown ? null : 'remote-run', + steerable: phase === SessionRuntimePhase.Tools, pending_steering_count: 0, queued_input_count: 0, + observation: { availability: 'live', epoch: 'remote-epoch', revision }, delegation_activity: [], ...extra + }); + const attached = (runtimeStatus?: SessionRuntimeStatus): RemoteSessionAttachInfo => ({ + session_id: id, node_id: 'node-1', attached: true, profile_id: 'owner-profile', profile_label: 'Owner', config_options: [], + snapshot: { cursor: { sessionSeq: 10 }, runtimeStatus, delegationUpdates: runtimeStatus?.delegation_activity ?? [], + audit: { events: [{ seq: 1, timestamp: 1, kind: { type: 'prompt_received', data: { message_id: 'owner-message', content: 'Owner history' } } }] } } + }); + async function storeOnSessionRoute() { + const store = createStore(); + await store.connectAgent('agent-1'); + store.activeAgentId = 'agent-1'; store.activeSessionId = 'old-session'; store.activeSession.sessionId = 'old-session'; + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle)); + store.setRuntimePollingEnabled(true); + await vi.advanceTimersByTimeAsync(0); + mockClient.getSessionRuntimeStatus.mockClear(); + mockClient.supportsQuerymtFeature.mockImplementation((feature) => feature === 'steering'); + return store; + } + beforeEach(() => { vi.useFakeTimers(); Object.defineProperty(document, 'hidden', { configurable: true, value: false }); }); + afterEach(() => { delete (document as { hidden?: boolean }).hidden; }); + + it('hydrates silent Tools immediately and keeps fresh reads/polling through the same-route shortcut', async () => { + const store = await storeOnSessionRoute(); + let first!: (value: SessionRuntimeStatus) => void; + let second!: (value: SessionRuntimeStatus) => void; + mockClient.getSessionRuntimeStatus + .mockImplementationOnce(() => new Promise((resolve) => { first = resolve; })) + .mockImplementationOnce(() => new Promise((resolve) => { second = resolve; })); + mockClient.attachRemoteSession.mockResolvedValueOnce(attached(runtime())); + await store.attachRemoteSession('agent-1', 'node-1', id); + expect(store.activeSessionRuntime).toMatchObject(runtime()); + expect(store.activeSession).toMatchObject({ runId: 'remote-run', runState: 'tool-running' }); + expect(store.activeRunBusy).toBe(true); + expect(store.canControlActiveRun).toBe(true); + store.setActiveComposerInputDelivery('steer'); + expect(store.activeInputDelivery).toBe('steer'); + expect(store.sessionsByAgent['agent-1'].find((item) => item.sessionId === id)).toMatchObject({ + status: 'thinking', location: 'remote', remoteNodeId: 'node-1', remoteProfileId: 'owner-profile' + }); + expect(store.activeSession.transcript[0]?.text).toBe('Owner history'); + expect(first).toBeDefined(); + await store.loadSession('agent-1', id); + expect(mockClient.loadSession).not.toHaveBeenCalled(); + expect(second).toBeDefined(); + first(runtime(SessionRuntimePhase.Idle, 3)); + await vi.advanceTimersByTimeAsync(0); + expect(store.activeSessionRuntime?.phase).toBe(SessionRuntimePhase.Tools); + second(runtime(SessionRuntimePhase.Tools, 5)); + await vi.advanceTimersByTimeAsync(0); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime(SessionRuntimePhase.Idle, 6)); + await vi.advanceTimersByTimeAsync(2_000); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledTimes(3); + expect(store.activeRunBusy).toBe(false); + }); + + it('seeds idle verifier cache, polls work, retains unavailable-empty and clears healthy-empty', async () => { + const store = await storeOnSessionRoute(); + let verify!: (value: SessionRuntimeStatus) => void; + const idle = runtime(SessionRuntimePhase.Idle, 4, { delegation_activity: [verifier] }); + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((resolve) => { verify = resolve; })); + mockClient.attachRemoteSession.mockResolvedValueOnce(attached(idle)); + await store.attachRemoteSession('agent-1', 'node-1', id); + expect(store.activeSessionRuntime).toMatchObject(idle); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + expect(store.activeRunBusy).toBe(false); + expect(mockClient.cancelSession).not.toHaveBeenCalled(); + verify(runtime(SessionRuntimePhase.Unknown, 4, { observation: { availability: 'unavailable' } })); + await vi.advanceTimersByTimeAsync(0); + expect(store.activeSession.runState).toBe('unknown'); + expect(store.sessionRuntimeBySession[key]?.delegation_activity).toEqual([verifier]); + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(idle); + await vi.advanceTimersByTimeAsync(2_000); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + expect(store.activeRunBusy).toBe(false); + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(runtime(SessionRuntimePhase.Idle, 4)); + await vi.advanceTimersByTimeAsync(2_000); + expect(store.activeSession.dependencyLabel).toBeNull(); + expect(store.sessionRuntimeBySession[key]?.delegation_activity).toEqual([]); + const calls = mockClient.getSessionRuntimeStatus.mock.calls.length; + await vi.advanceTimersByTimeAsync(6_000); + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledTimes(calls); + }); + + it.each(['unavailable', 'missing-runtime', 'missing-observation'] as const)('does not invent Idle for %s attach evidence', async (kind) => { + const store = await storeOnSessionRoute(); + const observed = kind === 'unavailable' ? runtime(SessionRuntimePhase.Unknown, 1, { observation: { availability: 'unavailable' } }) + : kind === 'missing-runtime' ? undefined : runtime(SessionRuntimePhase.Idle, 1, { observation: undefined }); + mockClient.attachRemoteSession.mockResolvedValueOnce(attached(observed)); + mockClient.supportsQuerymtMethod.mockReturnValue(false); + await store.attachRemoteSession('agent-1', 'node-1', id); + expect(store.activeSession.runState).toBe('unknown'); + expect(store.activeSessionRuntime?.observation?.availability).toBe('unavailable'); + expect(store.activeRunBusy).toBe(false); + expect(store.sessionsByAgent['agent-1'].find((item) => item.sessionId === id)?.status).toBe('unknown'); + expect(mockClient.getSessionRuntimeStatus).not.toHaveBeenCalled(); + }); + + it('preserves newer live incarnation/workflow and captured tool against an older attach snapshot', async () => { + const store = await storeOnSessionRoute(); + let finish!: (value: RemoteSessionAttachInfo) => void; + mockClient.attachRemoteSession.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + mockClient.getSessionRuntimeStatus.mockImplementation(() => new Promise(() => {})); + const attaching = store.attachRemoteSession('agent-1', 'node-1', id); + await vi.advanceTimersByTimeAsync(0); + const newer = runtime(SessionRuntimePhase.Tools, 1, { active_run_id: 'run-b', + observation: { availability: 'live', epoch: 'epoch-b', revision: 1 }, + delegation_activity: [{ ...verifier, sourceEpoch: 'workflow-b', stage: 'finalizing', sessionSeq: 20 }] }); + mockClient.emitExtensionNotification({ method: 'querymt/session/runtimeChanged', params: { version: 1, session_id: id, runtime: newer } }); + mockClient.emitSessionUpdate({ sessionId: id, update: { sessionUpdate: 'tool_call_update', toolCallId: 'tool-b', title: 'Live B', status: 'in_progress' } }); + const old = attached(runtime(SessionRuntimePhase.Idle, 99, { delegation_activity: [verifier] })); + old.snapshot.audit.events.push( + { seq: 2, kind: { type: 'run_started', data: { run_id: 'run-a' } } }, + { seq: 3, kind: { type: 'run_completed', data: { run_id: 'run-a', outcome: 'completed' } } } + ); + finish(old); + await attaching; + expect(store.activeSessionRuntime).toMatchObject(newer); + expect(store.sessionsByAgent['agent-1'].find((item) => item.sessionId === id)?.status).toBe('thinking'); + expect(store.activeSession).toMatchObject({ runId: 'run-b', runState: 'tool-running', dependencyLabel: 'Finalizing delegate result' }); + expect(store.activeSession.toolCalls).toContainEqual(expect.objectContaining({ id: 'tool-b', status: 'in_progress', runId: 'run-b' })); + expect(store.activeRunBusy).toBe(true); + }); + + it('rejects an attach response from a replaced client even with unchanged selection generation', async () => { + const store = await storeOnSessionRoute(); + let finish!: (value: RemoteSessionAttachInfo) => void; + mockClient.attachRemoteSession.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const attaching = store.attachRemoteSession('agent-1', 'node-1', id); + await vi.advanceTimersByTimeAsync(0); + await store.connectAgent('agent-1', true); + const selected = store.activeSession; + finish(attached(runtime())); + await attaching; + expect(store.activeSessionId).toBe('old-session'); + expect(store.activeSession).toBe(selected); + expect(store.lastRemoteAttachByAgent['agent-1']).toBeUndefined(); + }); + + it('supersedes a pre-attach cache read without waiting for or accepting its old result', async () => { + const store = await storeOnSessionRoute(); + let oldRead!: (value: SessionRuntimeStatus) => void; + let freshRead!: (value: SessionRuntimeStatus) => void; + mockClient.getSessionRuntimeStatus + .mockImplementationOnce(() => new Promise((resolve) => { oldRead = resolve; })) + .mockImplementationOnce(() => new Promise((resolve) => { freshRead = resolve; })); + const old = store.refreshSessionRuntime('agent-1', id); + await vi.advanceTimersByTimeAsync(0); + mockClient.attachRemoteSession.mockResolvedValueOnce(attached(runtime())); + await store.attachRemoteSession('agent-1', 'node-1', id); + expect(freshRead).toBeDefined(); + oldRead(runtime(SessionRuntimePhase.Idle, 99)); + expect(await old).toBeNull(); + expect(store.activeSessionRuntime?.phase).toBe(SessionRuntimePhase.Tools); + freshRead(runtime(SessionRuntimePhase.Tools, 5)); + await vi.advanceTimersByTimeAsync(0); + expect(store.activeSessionRuntime?.observation?.revision).toBe(5); + }); + + it.each(['selection', 'client'] as const)('fences a delayed attach catalog refresh after %s replacement', async (boundary) => { + const store = await storeOnSessionRoute(); + let catalog!: (value: Awaited>) => void; + mockClient.listRemoteSessions.mockImplementationOnce(() => new Promise((resolve) => { catalog = resolve; })); + mockClient.getSessionRuntimeStatus.mockResolvedValue(runtime()); + mockClient.attachRemoteSession.mockResolvedValueOnce(attached(runtime())); + const attaching = store.attachRemoteSession('agent-1', 'node-1', id); + await vi.advanceTimersByTimeAsync(0); + if (boundary === 'client') await store.connectAgent('agent-1', true); + else { + mockClient.attachRemoteSession.mockResolvedValueOnce({ ...attached(runtime()), session_id: 'new-session' }); + await store.attachRemoteSession('agent-1', 'node-1', 'new-session'); + } + const selected = store.activeSession; + const before = store.remoteSessionsByAgent; + catalog({ node_id: 'node-1', total_count: 0, sessions: [] }); + await attaching; + expect(store.remoteSessionsByAgent).toBe(before); + expect(store.activeSession).toBe(selected); + }); + + it('keeps ordinary remote load snapshot truth while its independent runtime read is pending', async () => { + const store = await storeOnSessionRoute(); + const source = attached(runtime()); + let verify!: (value: SessionRuntimeStatus) => void; + mockClient.loadSession.mockResolvedValueOnce({ response: { configOptions: [], _meta: { + location: 'remote', nodeId: 'node-1', profileId: 'owner-profile', parentSessionId: 'parent', forkOrigin: 'delegation', + 'querymt/sessionLoadSnapshot.v1': source.snapshot + } }, replay: [] }); + mockClient.getSessionRuntimeStatus.mockImplementationOnce(() => new Promise((resolve) => { verify = resolve; })); + const loading = store.loadSession('agent-1', id); + await vi.waitFor(() => expect(verify).toBeDefined()); + expect(store.activeSessionRuntime).toMatchObject(runtime()); + expect(store.activeSession.runState).toBe('tool-running'); + expect(store.activeSession.transcript[0]?.text).toBe('Owner history'); + expect(store.sessionsByAgent['agent-1'].find((item) => item.sessionId === id)).toMatchObject({ + remoteNodeId: 'node-1', remoteProfileId: 'owner-profile', parentSessionId: 'parent', forkOrigin: 'delegation' + }); + verify(runtime(SessionRuntimePhase.Tools, 5)); + await vi.advanceTimersByTimeAsync(100); + await loading; + expect(store.activeRunBusy).toBe(true); + }); +}); + describe('AgentsStore visibility-aware polling', () => { const activeRuntime: SessionRuntimeStatus = { phase: SessionRuntimePhase.Model, active_run_id: 'run-1', steerable: true, pending_steering_count: 0, queued_input_count: 0 }; const listed: SessionInfo = { sessionId: 'session-1', title: 'Task', cwd: '/tmp/work', updatedAt: '2026-10-20T20:00:00Z' }; diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts index 83ace8ba..16d55a2b 100644 --- a/src/lib/stores/agents.svelte.ts +++ b/src/lib/stores/agents.svelte.ts @@ -4254,6 +4254,16 @@ export class AgentsStore { if ((hydrated || fresh) && this.isSelectedSession(agentId, sessionId)) { this.acknowledgeSession(agentId, sessionId); this.rememberRecentSession(agentId, sessionId); + if (hydrated) { + const record = this.initializedClientRecord(agentId); + if (record) { + this.projectSelectedExecution(agentId, sessionId); + // Keep the hydrated handoff's fresh runtime query independent of the first-load shortcut. + this.cancelSessionRuntimeRefresh(sessionKey); + this.sessionRuntimeRefreshGenerations.set(sessionKey, (this.sessionRuntimeRefreshGenerations.get(sessionKey) ?? 0) + 1); + void this.refreshSessionRuntime(agentId, sessionId, record.client).catch(() => null); + } + } await this.refreshDelegateAssignments(agentId, sessionId); return; } @@ -5667,12 +5677,13 @@ export class AgentsStore { return result; } - async refreshRemoteSessionsForAgent(agentId: string, node_id: string) { + async refreshRemoteSessionsForAgent(agentId: string, node_id: string, isCurrent = () => true) { const record = await this.connectInitializedRecord(agentId); if (!record) { throw new Error('Failed to connect to the agent.'); } const result = await record.client.listRemoteSessions({ node_id }); + if (!isCurrent() || this.clients.get(agentId) !== record) return result; this.remoteSessionsByAgent = { ...this.remoteSessionsByAgent, [agentId]: { @@ -5750,35 +5761,41 @@ export class AgentsStore { async attachRemoteSession(agentId: string, node_id: string, session_id: string, expectedGeneration?: number) { const generation = expectedGeneration ?? ++this.sessionSelectionGeneration; const remoteSession = this.remoteSessionsByAgent[agentId]?.[node_id]?.sessions.find((session) => session.id === session_id); - let result: RemoteSessionAttachInfo; + let record: AgentClientRecord | undefined; + let unsubscribe = () => {}; try { - const record = await this.connectInitializedRecord(agentId); - if (generation !== this.sessionSelectionGeneration) return session_id; - if (!record) { - throw new Error('Failed to connect to the agent.'); - } - result = await record.client.attachRemoteSession({ node_id, session_id }); + record = await this.connectInitializedRecord(agentId); if (generation !== this.sessionSelectionGeneration) return session_id; + if (!record) throw new Error('Failed to connect to the agent.'); + const key = buildSessionKey(agentId, session_id); + this.sessionRuntimeRefreshGenerations.set(key, (this.sessionRuntimeRefreshGenerations.get(key) ?? 0) + 1); + const initialRuntime = this.sessionRuntimeBySession[key]; + const initialActivity = this.sessionDelegations.get(key); + // Capture only this attach's session updates; extensions keep their normal ordered cache path. + const replay: SessionNotification[] = []; + unsubscribe = record.client.onSessionUpdate((notification) => { + if (notification.sessionId === session_id) replay.push(notification); + }); + const result = await record.client.attachRemoteSession({ node_id, session_id }); + unsubscribe(); + if (generation !== this.sessionSelectionGeneration || this.clients.get(agentId) !== record) return session_id; if (result.session_id !== session_id || result.node_id !== node_id) { throw new Error('Remote attachment identity does not match the requested session.'); } + this.lastRemoteAttachByAgent = { ...this.lastRemoteAttachByAgent, [agentId]: result }; + const discovered = this.sessionsByAgent[agentId]?.find((session) => session.sessionId === session_id && session.remoteNodeId === node_id); + return await this.hydrateRemoteAttach(agentId, node_id, result, remoteSession ?? { + id: session_id, node_id, title: discovered?.title, cwd: discovered?.cwd, updated_at: discovered?.updatedAt ?? undefined, + profile_id: discovered?.remoteProfileId, profile_label: discovered?.remoteProfileLabel + }, true, true, { record, initialRuntime, initialActivity, replay }); } catch (error) { - // A fallback load owns its cleanup; only an explicit attachment can - // inherit loading from the operation it superseded. - if (expectedGeneration === undefined && generation === this.sessionSelectionGeneration) { - this.sessionHistoryLoading = false; - } + // A fallback load owns its cleanup; only an explicit attachment can inherit loading. + if (expectedGeneration === undefined && generation === this.sessionSelectionGeneration && + (!record || this.clients.get(agentId) === record)) this.sessionHistoryLoading = false; throw error; + } finally { + unsubscribe(); } - this.lastRemoteAttachByAgent = { - ...this.lastRemoteAttachByAgent, - [agentId]: result - }; - const discovered = this.sessionsByAgent[agentId]?.find((session) => session.sessionId === session_id && session.remoteNodeId === node_id); - return this.hydrateRemoteAttach(agentId, node_id, result, remoteSession ?? { - id: session_id, node_id, title: discovered?.title, cwd: discovered?.cwd, updated_at: discovered?.updatedAt ?? undefined, - profile_id: discovered?.remoteProfileId, profile_label: discovered?.remoteProfileLabel - }, true); } /** Publish the owner's snapshot and metadata after attachment validation. */ @@ -5790,33 +5807,53 @@ export class AgentsStore { profile_id?: string | null; profile_label?: string | null }, preserveSelectionGeneration = false, /** False publishes the session (catalog, model, preferences) without taking over the view. */ - select = true + select = true, + request?: { + record: AgentClientRecord; + initialRuntime: SessionRuntimeStatus | null | undefined; + initialActivity: DelegationUpdateNotification[] | undefined; + replay: SessionNotification[]; + } ): Promise { const config = this.configs.find((candidate) => candidate.id === agentId); if (!config) throw new Error(`Unable to locate agent ${agentId}.`); const sessionId = result.session_id; + const key = buildSessionKey(agentId, sessionId); + const record = request?.record ?? this.initializedClientRecord(agentId); + const snapshotIsCurrent = this.sessionRuntimeBySession[key] === request?.initialRuntime && + this.sessionDelegations.get(key) === request?.initialActivity; + if (select) this.resetActiveSession(agentId, sessionId, preserveSelectionGeneration); + const generation = this.sessionSelectionGeneration; + const isCurrent = () => (!select || (generation === this.sessionSelectionGeneration && this.isSelectedSession(agentId, sessionId))) && + (!record || this.clients.get(agentId) === record); + const response = { _meta: { 'querymt/sessionLoadSnapshot.v1': result.snapshot } }; const configOptions = result.config_options ?? []; + const snapshot = activeSessionFromLoadResponse(sessionId, response); + snapshot.configOptions = configOptions; if (select) { - this.resetActiveSession(agentId, sessionId, preserveSelectionGeneration); - const snapshot = activeSessionFromLoadResponse(sessionId, { - _meta: { 'querymt/sessionLoadSnapshot.v1': result.snapshot } - }); - snapshot.configOptions = configOptions; - this.activeSession = normalizeHistoricalSession(snapshot, { loadCompleted: true }); this.rememberSnapshotUserMessages(snapshot); + const replay = (request?.replay ?? []).filter((notification) => !this.isSnapshotUserEcho(notification)); + this.activeSession = appendSessionReplay(snapshot, replay); + for (const { update } of replay) { + if (update.sessionUpdate !== 'tool_call' && update.sessionUpdate !== 'tool_call_update') continue; + const tool = this.activeSession.toolCalls.find((item) => item.id === update.toolCallId); + if (tool && tool.runId === undefined && (tool.status === 'pending' || tool.status === 'in_progress')) tool.runId = null; + } + if (record) record.recentSessionUpdateKeys = replay.map(getSessionNotificationKey).slice(-200); this.rememberSessionChildren(agentId, sessionId, this.activeSession.toolCalls.map((tool) => tool.childSessionId)); - this.reconcileInputStatesFromSnapshot(agentId, sessionId, { - _meta: { 'querymt/sessionLoadSnapshot.v1': result.snapshot } - }); + this.reconcileInputStatesFromSnapshot(agentId, sessionId, response); } this.applySessionConfigOptionsSnapshot(agentId, sessionId, configOptions); const current = getSessionById(this.sessionsByAgent[agentId] ?? [], sessionId); + const hasUserHistory = snapshot.transcript.some((item) => item.kind === 'user_message_chunk') ? true : current?.hasUserHistory; + const cachedRuntime = this.sessionRuntimeBySession[key]; const summary: DesktopSessionSummary = { ...current, agentId, agentName: config.name, sessionId, + hasUserHistory, relationshipKnown: current?.relationshipKnown ?? false, title: remoteSession.title?.trim() || sessionId, cwd: remoteSession.cwd ?? '', @@ -5831,12 +5868,20 @@ export class AgentsStore { remoteProfileLabel: result.profile_label === undefined && (result.profile_id === undefined || result.profile_id === remoteSession.profile_id) ? remoteSession.profile_label ?? undefined : result.profile_label ?? undefined, - status: 'idle' + status: cachedRuntime?.observation ? runtimeSessionStatus(cachedRuntime, hasUserHistory) : 'unknown' }; this.sessionsByAgent = { ...this.sessionsByAgent, [agentId]: mergeSessions(this.sessionsByAgent[agentId] ?? [], [summary]) }; + this.reconcileManagedActivity(agentId, sessionId, snapshot.delegations ?? [], false, snapshotIsCurrent); + const snapshotRuntime = getSnapshotRuntimeStatus(response); + this.reconcileSessionRuntime(agentId, sessionId, snapshotRuntime?.observation ? snapshotRuntime : { + phase: 'unknown' as SessionRuntimeStatus['phase'], active_run_id: null, steerable: false, + pending_steering_count: 0, queued_input_count: 0, observation: { availability: 'unavailable' } + }, snapshotIsCurrent, snapshotIsCurrent); + this.projectSelectedExecution(agentId, sessionId); + if (select && record) void this.refreshSessionRuntime(agentId, sessionId, record.client).catch(() => null); // Resolve owner-local model identities only after publishing the owner node. this.restoreSessionModel(agentId, sessionId, configOptions, { _meta: { 'querymt/sessionLoadSnapshot.v1': result.snapshot } @@ -5846,8 +5891,8 @@ export class AgentsStore { if (select) this.hydratedRemoteSessionKeys.add(buildSessionKey(agentId, sessionId)); this.rememberRecentSession(agentId, sessionId); // Attachment is committed; a retryable catalog refresh must not fail its load. - await this.refreshRemoteSessionsForAgent(agentId, nodeId).catch((error) => { - console.warn('Remote session attached, but catalog refresh failed', error); + await this.refreshRemoteSessionsForAgent(agentId, nodeId, isCurrent).catch((error) => { + if (isCurrent()) console.warn('Remote session attached, but catalog refresh failed', error); }); return sessionId; } From d1328ebf68b3dd23704eadecbe0175ced981c5e3 Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Fri, 30 Oct 2026 19:09:07 +0000 Subject: [PATCH 5/8] fix(ui): reconcile retained delegation lifecycle --- src/lib/domain/session-tool-presentation.ts | 4 +- src/lib/domain/session-updates.test.ts | 69 +++++++- src/lib/domain/session-updates.ts | 95 ++++++++++- src/lib/domain/types.ts | 2 + src/lib/stores/agents.svelte.test.ts | 167 ++++++++++++++++++++ src/lib/stores/agents.svelte.ts | 22 ++- 6 files changed, 344 insertions(+), 15 deletions(-) diff --git a/src/lib/domain/session-tool-presentation.ts b/src/lib/domain/session-tool-presentation.ts index 720ed9ae..174ad821 100644 --- a/src/lib/domain/session-tool-presentation.ts +++ b/src/lib/domain/session-tool-presentation.ts @@ -54,8 +54,10 @@ export function getSessionToolPresentation(tool: SessionToolCallItem): SessionTo const resultText = formatTechnicalText(tool.result); const delegate = isDelegateToolName(name); const state = delegate ? tool.delegationState : undefined; + const unavailable = delegate && !state && tool.delegationObservation?.activity === 'historical'; // A successful dispatch does not mean the delegated task has finished. const status = !delegate ? tool.status + : unavailable ? 'pending' : state === 'forked' ? 'in_progress' : state === 'completed' ? 'completed' : state === 'failed' || state === 'cancelled' || tool.status === 'failed' ? 'failed' @@ -68,7 +70,7 @@ export function getSessionToolPresentation(tool: SessionToolCallItem): SessionTo icon: toolIcon(name), status, statusLabel: state === 'cancelled' ? 'Cancelled' - : delegate && !state && tool.status !== 'failed' ? 'Status unavailable' + : delegate && !state && (unavailable || tool.status !== 'failed') ? 'Status unavailable' : statusLabel(status), expandable: Boolean(argumentsText || resultText), argumentsText, diff --git a/src/lib/domain/session-updates.test.ts b/src/lib/domain/session-updates.test.ts index 1b2413b1..a74850cd 100644 --- a/src/lib/domain/session-updates.test.ts +++ b/src/lib/domain/session-updates.test.ts @@ -12,7 +12,8 @@ import { endSessionWork, getNextConversationEventIndex, mergeMissingSessionUsage, - projectSessionRuntime + projectSessionRuntime, + projectDelegationActivity } from './session-updates'; function notification(update: SessionNotification['update']): SessionNotification { @@ -449,6 +450,72 @@ describe('applySessionNotification tool calls', () => { expect(next.toolCalls[0]).toMatchObject({ status: 'completed', arguments: '{}', result: 'Option B' }); }); + it('joins retained lifecycle after bulk replay creates duplicate canonical tools', () => { + let session = createEmptyActiveSession(); session.sessionId = 'session-1'; + session = applyDelegationChildSession(session, { sessionId: 'session-1', delegationId: 'd1', toolCallId: 'delegate-1', + state: 'completed', childSessionId: 'child-1', updatedAt: 3, sessionSeq: 3, sourceEpoch: 'workflow-1' }); + session.toolCalls.push({ id: 'delegate-1', title: 'Run delegate', kind: 'delegate', status: 'completed' }); + appendSessionReplay(session, [notification({ sessionUpdate: 'tool_call', toolCallId: 'delegate-1', title: 'Run delegate', status: 'completed' })]); + expect(session.toolCalls).toHaveLength(1); + expect(session.toolCalls[0]).toMatchObject({ delegationState: 'completed', childSessionId: 'child-1' }); + }); + + it('keeps canonical terminal truth while enriching late missing tool and child identity', () => { + let session = createEmptyActiveSession(); session.sessionId = 'session-1'; + const terminal = { sessionId: 'session-1', delegationId: 'd1', targetAgentId: 'coder', state: 'completed' as const, updatedAt: 3, sessionSeq: 3, sourceEpoch: 'workflow-1' }; + session = applyDelegationChildSession(session, terminal); + session = applySessionNotification(session, notification({ sessionUpdate: 'tool_call', toolCallId: 'delegate-1', title: 'Run delegate', status: 'completed' })); + session = applyDelegationChildSession(session, { ...terminal, state: 'forked', updatedAt: 2, sessionSeq: 2, + toolCallId: 'delegate-1', childSessionId: 'child-1' }); + expect(session.delegations?.[0]).toMatchObject({ state: 'completed', sessionSeq: 3, toolCallId: 'delegate-1' }); + expect(session.toolCalls[0]).toMatchObject({ delegationState: 'completed', childSessionId: 'child-1' }); + session = applyDelegationChildSession(session, { ...terminal, toolCallId: 'delegate-1', childSessionId: 'child-1', state: 'forked', sessionSeq: 4 }); + expect(session.toolCalls[0].delegationState).toBe('completed'); + }); + + it('preserves a terminal row without comparable provenance but repairs an older snapshot projection', () => { + let session = createEmptyActiveSession(); session.sessionId = 'session-1'; + session.toolCalls = [{ id: 'delegate-1', title: 'Run delegate', kind: 'delegate', status: 'completed', delegationState: 'completed', delegationUpdatedAt: 9 }]; + const update = { sessionId: 'session-1', delegationId: 'd1', toolCallId: 'delegate-1', targetAgentId: 'coder', + state: 'failed' as const, updatedAt: 3, sessionSeq: 3, sourceEpoch: 'workflow-1' }; + session = applyDelegationChildSession(session, { ...update, state: 'forked', sessionSeq: 2, updatedAt: 2, childSessionId: 'child-1' }); + expect(session.toolCalls[0]).toMatchObject({ delegationState: 'completed', delegationUpdatedAt: 9 }); + session = applyDelegationChildSession(session, { ...update, childSessionId: 'child-1' }); + expect(session.toolCalls[0]).toMatchObject({ delegationState: 'completed', delegationUpdatedAt: 9, childSessionId: 'child-1' }); + expect(session.toolCalls[0].delegationObservation).toBeUndefined(); + const snapshot = activeSessionFromLoadResponse('session-1', { _meta: { 'querymt/sessionLoadSnapshot.v1': { + audit: { events: [{ seq: 1, kind: { type: 'tool_call_start', data: { tool_call_id: 'delegate-1', tool_name: 'delegate' } } }] }, + delegationUpdates: [update] + } } }); + const newer = applyDelegationChildSession(createEmptyActiveSession(), { ...update, state: 'completed', sessionSeq: 4, updatedAt: 4 }); + // A retained newer observation wins over an explicitly older projection of the same source/identity. + snapshot.delegations = newer.delegations; + projectDelegationActivity(snapshot); + expect(snapshot.toolCalls[0].delegationState).toBe('completed'); + for (const state of ['forked', 'failed'] as const) { + snapshot.delegations = applyDelegationChildSession(createEmptyActiveSession(), { + ...update, state, sourceEpoch: 'another-workflow', sessionSeq: state === 'forked' ? 2 : 3 + }).delegations; + projectDelegationActivity(snapshot); + expect(snapshot.toolCalls[0].delegationState).toBe('completed'); + expect(snapshot.toolCalls[0].delegationObservation?.sourceEpoch).toBe('workflow-1'); + } + }); + + it('does not join another session, conflicting child, or ambiguous delegation onto the same tool id', () => { + let session = createEmptyActiveSession(); session.sessionId = 'session-1'; + session.toolCalls = [{ id: 'delegate-1', title: 'Run delegate', kind: 'delegate', status: 'completed', childSessionId: 'actual-child' }]; + const update = { sessionId: 'other-session', delegationId: 'd1', toolCallId: 'delegate-1', childSessionId: 'wrong-child', state: 'completed' as const, updatedAt: 3 }; + session = applyDelegationChildSession(session, update); + expect(session.delegations).toBeUndefined(); + session = applyDelegationChildSession(session, { ...update, sessionId: 'session-1' }); + expect(session.toolCalls[0]).toMatchObject({ childSessionId: 'actual-child' }); + expect(session.toolCalls[0].delegationState).toBeUndefined(); + session.toolCalls[0].childSessionId = undefined; + session = applyDelegationChildSession(session, { ...update, sessionId: 'session-1', delegationId: 'd2' }); + expect(session.toolCalls[0].delegationState).toBeUndefined(); + }); + it('attaches a child session id from a live delegation update', () => { const session = createEmptyActiveSession(); session.sessionId = 'session-1'; diff --git a/src/lib/domain/session-updates.ts b/src/lib/domain/session-updates.ts index ae68974a..f0430010 100644 --- a/src/lib/domain/session-updates.ts +++ b/src/lib/domain/session-updates.ts @@ -77,6 +77,7 @@ export function appendSessionReplay( nextEventIndex = currentConversationEventIndex(session); } + projectDelegationActivity(session); return session; } @@ -524,6 +525,7 @@ export function applySessionNotification( break; } + projectDelegationActivity(next); return next; } @@ -580,10 +582,37 @@ export function mergeDelegationActivity( const items = new Map(current.map((item) => [item.delegationId, item])); for (const update of incoming) { const previous = items.get(update.delegationId); + if (previous && (previous.sessionId !== update.sessionId || + (previous.targetAgentId && update.targetAgentId && previous.targetAgentId !== update.targetAgentId) || + (previous.toolCallId && update.toolCallId && previous.toolCallId !== update.toolCallId) || + (previous.childSessionId && update.childSessionId && previous.childSessionId !== update.childSessionId))) continue; if (previous?.sourceEpoch && previous.sourceEpoch !== update.sourceEpoch && !allowNewSource) continue; - if (previous?.sourceEpoch === update.sourceEpoch && previous?.sessionSeq !== undefined && - (update.sessionSeq === undefined || update.sessionSeq <= previous.sessionSeq)) continue; - if (previous?.activity === 'live' && update.activity !== 'live' && update.sessionSeq === undefined) continue; + const staleOrder = previous?.sourceEpoch === update.sourceEpoch && previous?.sessionSeq !== undefined && + (update.sessionSeq === undefined || update.sessionSeq <= previous.sessionSeq); + const staleTime = previous && previous.sessionSeq === undefined && update.sessionSeq === undefined && + (update.updatedAt < previous.updatedAt || (previous.state === 'forked' && update.state === 'requested')); + const terminalRegression = previous && isTerminalDelegation(previous.state) && update.state !== previous.state; + // History seeding can recover durable lifecycle facts behind an unchanged live stage watermark. + const seededFact = previous?.sourceEpoch && previous.sourceEpoch === update.sourceEpoch && + previous.sessionSeq !== undefined && previous.sessionSeq === update.sessionSeq && + previous.targetAgentId && previous.targetAgentId === update.targetAgentId && previous.requestedAt === update.requestedAt && + ((previous.state === 'requested' && update.state === 'forked') || + ((previous.state === 'requested' || previous.state === 'forked') && isTerminalDelegation(update.state))); + if (seededFact) { + items.set(update.delegationId, { ...previous, ...update, activity: previous.activity, stage: previous.stage, + updatedAt: Math.max(previous.updatedAt, update.updatedAt) }); + continue; + } + if (staleOrder || staleTime || terminalRegression || + (previous?.activity === 'live' && update.activity !== 'live' && update.sessionSeq === undefined)) { + // Late history can fill correlation metadata, never replace accepted lifecycle/order. + if (previous?.targetAgentId && previous.targetAgentId === update.targetAgentId && + ((!previous.toolCallId && update.toolCallId) || (!previous.childSessionId && update.childSessionId))) { + items.set(update.delegationId, { ...previous, toolCallId: previous.toolCallId ?? update.toolCallId, + childSessionId: previous.childSessionId ?? update.childSessionId }); + } + continue; + } items.set(update.delegationId, { ...previous, ...update }); } if (complete) { @@ -596,8 +625,59 @@ export function mergeDelegationActivity( return merged.length === current.length && merged.every((item, index) => item === current[index]) ? current : merged; } +function isTerminalDelegation(state: string | undefined): boolean { + return state === 'completed' || state === 'failed' || state === 'cancelled'; +} + export function projectDelegationActivity(session: ActiveSessionViewModel): void { - const live = (session.delegations ?? []).filter((item) => item.activity === 'live'); + if (!session.delegations?.length) { + session.dependencyLabel = null; + return; + } + // One join for live, cached, replayed and snapshot observations. Dispatch completion is not child completion. + const byTool = new Map(); + for (const observation of session.delegations ?? []) { + if (observation.sessionId !== session.sessionId || !observation.toolCallId) continue; + const previous = byTool.get(observation.toolCallId); + byTool.set(observation.toolCallId, previous === undefined ? observation : null); + } + let joined: SessionToolCallItem[] | undefined; + for (const [index, tool] of session.toolCalls.entries()) { + const observation = byTool.get(tool.id); + if (!observation || tool.delegationObservation === observation || + (tool.childSessionId && observation.childSessionId && tool.childSessionId !== observation.childSessionId)) continue; + const previous = tool.delegationObservation; + if (previous && (previous.delegationId !== observation.delegationId || previous.sessionId !== observation.sessionId || + (previous.targetAgentId && observation.targetAgentId && previous.targetAgentId !== observation.targetAgentId))) continue; + if (tool.arguments && observation.targetAgentId) { + try { + const target = JSON.parse(tool.arguments)?.target_agent_id; + if (typeof target === 'string' && target !== observation.targetAgentId) continue; + } catch { /* Some providers deliver arguments as text rather than JSON. */ } + } + const terminal = isTerminalDelegation(tool.delegationState); + const observedTerminal = isTerminalDelegation(observation.state); + if (terminal && observedTerminal && tool.delegationState !== observation.state) { + // Only comparable retained truth can repair an older snapshot projection's terminal kind. + const newer = previous && previous.state === tool.delegationState && previous.sourceEpoch === observation.sourceEpoch && + (previous.sessionSeq !== undefined && observation.sessionSeq !== undefined + ? observation.sessionSeq > previous.sessionSeq + : previous.sessionSeq === undefined && observation.sessionSeq === undefined && observation.updatedAt > previous.updatedAt); + if (!newer) continue; + } + const state = observedTerminal ? observation.state : terminal ? tool.delegationState + : observation.activity === 'historical' ? undefined : observation.state; + const childSessionId = tool.childSessionId ?? observation.childSessionId; + const updatedAt = terminal && !observedTerminal ? tool.delegationUpdatedAt : observation.updatedAt; + // Nonterminal metadata cannot establish ordering for an outcome it did not support. + const supporting = terminal && !observedTerminal ? previous : observation; + if (tool.delegationState === state && tool.childSessionId === childSessionId && + tool.delegationUpdatedAt === updatedAt && tool.delegationObservation === supporting) continue; + joined ??= session.toolCalls.slice(); + joined[index] = { ...tool, childSessionId, delegationState: state, delegationUpdatedAt: updatedAt, delegationObservation: supporting }; + } + if (joined) session.toolCalls = joined; + const live = (session.delegations ?? []).filter((item) => item.sessionId === session.sessionId && item.activity === 'live'); session.dependencyLabel = live.some((item) => item.stage === 'verifying') ? 'Verifying delegate result' : live.some((item) => item.stage === 'finalizing') ? 'Finalizing delegate result' : live.some((item) => item.stage === 'cancel_requested') ? 'Delegate cancellation requested' @@ -881,10 +961,13 @@ export function applyDelegationChildSession( const childSessionId = readNonEmptyString(update.childSessionId) ?? readNonEmptyString(update.child_session_id); if (sessionId && current.sessionId && sessionId !== current.sessionId) return current; if (update.delegationId) { - current = { ...current, delegations: mergeDelegationActivity(current.delegations ?? [], [update as DelegationUpdateNotification]) }; - projectDelegationActivity(current); + const next = { ...current, delegations: mergeDelegationActivity(current.delegations ?? [], [update as DelegationUpdateNotification]) }; + projectDelegationActivity(next); + return next; } if (!toolCallId) return current; + // A partial/ID-only extension must not bypass a canonical observation's identity/order gates. + if (current.delegations?.some((item) => item.sessionId === current.sessionId && item.toolCallId === toolCallId)) return current; const target = current.toolCalls.find((tool) => tool.id === toolCallId); if (!target) return current; diff --git a/src/lib/domain/types.ts b/src/lib/domain/types.ts index 610c34c8..3f31097e 100644 --- a/src/lib/domain/types.ts +++ b/src/lib/domain/types.ts @@ -333,6 +333,8 @@ export interface SessionToolCallItem { // The dispatch tool can finish before its delegated task does. delegationState?: `${DelegationUpdateState}`; delegationUpdatedAt?: number; + /** Internal join provenance; never a new wire field or a dispatch outcome. */ + delegationObservation?: DelegationUpdateNotification; } export interface SessionPlanEntry { diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts index f59f0057..155b488f 100644 --- a/src/lib/stores/agents.svelte.test.ts +++ b/src/lib/stores/agents.svelte.test.ts @@ -3,6 +3,9 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/svelte'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import SessionActionsMenu from '$lib/components/primitives/SessionActionsMenu.svelte'; import SessionActivityBar from '$lib/components/session/SessionActivityBar.svelte'; +import SessionToolBlock from '$lib/components/session/SessionToolBlock.svelte'; +import SessionDelegatesBubble from '$lib/components/session/SessionDelegatesBubble.svelte'; +import { getSessionToolPresentation } from '$lib/domain/session-tool-presentation'; import SessionHeader from '$lib/components/session/SessionHeader.svelte'; import { RequestError, type InitializeResponse, type ListSessionsRequest, type PromptResponse, type SessionConfigOption, type SessionInfo, type SessionNotification, type SetSessionConfigOptionRequest } from '@agentclientprotocol/sdk'; import type { AgentConfig, DesktopSessionSummary, ModelEntry, PromptAttachment, PromptSendOptions } from '$lib/domain/types'; @@ -6080,6 +6083,170 @@ describe('AgentsStore mesh node availability', () => { }); }); +describe('AgentsStore delegation lifecycle row reconciliation', () => { + afterEach(cleanup); + const lifecycle = (state = DelegationUpdateState.Forked, seq = 2, extra: Record = {}) => ({ + version: 1, sessionId: 'session-1', delegationId: 'delegation-1', toolCallId: 'delegate-1', + childSessionId: 'child-1', targetAgentId: 'coder', objective: 'Check result', requestedAt: 1, updatedAt: seq, + state, activity: state === DelegationUpdateState.Forked ? 'live' : 'historical', + sourceEpoch: 'workflow-1', sessionSeq: seq, ...extra + }); + const emit = (params: ReturnType) => mockClient.emitExtensionNotification({ + method: 'querymt/session/delegationUpdate', params + }); + const dispatch = () => mockClient.emitSessionUpdate({ sessionId: 'session-1', update: { + sessionUpdate: 'tool_call', toolCallId: 'delegate-1', title: 'Run delegate', status: 'completed', + rawInput: { target_agent_id: 'coder' }, rawOutput: { status: 'queued' } + } }); + const idle = (updates: ReturnType[] = [], revision = 1): SessionRuntimeStatus => ({ + phase: SessionRuntimePhase.Idle, active_run_id: null, steerable: false, pending_steering_count: 0, queued_input_count: 0, + observation: { availability: 'live', epoch: 'actor-1', revision }, + delegation_activity: updates as SessionRuntimeStatus['delegation_activity'] + }); + const announce = (updates: ReturnType[], revision = 1) => mockClient.emitExtensionNotification({ + method: 'querymt/session/runtimeChanged', params: { version: 1, session_id: 'session-1', runtime: idle(updates, revision) } + }); + async function selected() { + const store = createStore(); await store.connectAgent('agent-1'); + store.activeAgentId = 'agent-1'; store.activeSessionId = 'session-1'; store.activeSession.sessionId = 'session-1'; + return store; + } + function snapshot(state = DelegationUpdateState.Forked) { + return { audit: { events: [ + { seq: 1, kind: { type: 'tool_call_start', data: { tool_call_id: 'delegate-1', tool_name: 'delegate' } } }, + { seq: 2, kind: { type: 'tool_call_end', data: { tool_call_id: 'delegate-1', result: 'queued' } } } + ] }, delegationUpdates: [lifecycle(state, 2)] }; + } + + it.each([DelegationUpdateState.Forked, DelegationUpdateState.Completed])('joins serialized %s before tool creation into transcript and turn delegates', async (state) => { + const store = await selected(); + emit(JSON.parse(JSON.stringify(lifecycle(state)))); dispatch(); + expect(store.activeSession.toolCalls[0]).toMatchObject({ status: 'completed', childSessionId: 'child-1', delegationState: state }); + const view = render(SessionToolBlock, { tool: store.activeSession.toolCalls[0] }); + const expected = state === DelegationUpdateState.Forked ? 'in_progress' : 'completed'; + expect(view.container.querySelector(`.session-tool-status-${expected}`)).not.toBeNull(); + render(SessionDelegatesBubble, { agentId: 'agent-1', variant: 'turn', turn: buildSessionConversation(store.activeSession)[0] }); + await fireEvent.click(screen.getByRole('button', { name: '1 delegate in this turn' })); + expect(screen.getByRole('img', { name: state === DelegationUpdateState.Forked ? 'Running' : 'Completed' })).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', '/sessions/agent-1/child-1'); + }); + + it('joins runtime terminal truth, ignores stale extensions, and invalidates the settled projection', async () => { + const store = await selected(); dispatch(); emit(lifecycle()); announce([], 1); + const before = buildSessionConversation(store.activeSession); + const view = render(SessionToolBlock, { tool: store.activeSession.toolCalls[0] }); + announce([lifecycle(DelegationUpdateState.Completed, 3)], 2); + emit(lifecycle(DelegationUpdateState.Failed, 1)); + expect(store.activeSession.toolCalls[0].delegationState).toBe('completed'); + expect(store.activeSession.delegations?.[0].state).toBe('completed'); + const after = buildSessionConversation(store.activeSession); expect(after).not.toBe(before); + const tool = after[0].content.find((item) => item.type === 'tool')!.tool; + await view.rerender({ tool }); + expect(view.container.querySelector('.session-tool-status-completed .lucide-check')).not.toBeNull(); + expect(view.container.querySelector('.animate-spin')).toBeNull(); + expect(store.activeRunBusy).toBe(false); + }); + + it('accepts source-seeded fork and terminal facts at the retained stage watermark without changing stage liveness', async () => { + const store = await selected(); dispatch(); + emit(lifecycle(DelegationUpdateState.Requested, 50, { childSessionId: undefined, stage: 'verifying', activity: 'live' })); + emit(lifecycle(DelegationUpdateState.Forked, 50, { forkedAt: 2, updatedAt: 50, stage: undefined, activity: 'historical' })); + expect(store.activeSession.toolCalls[0]).toMatchObject({ delegationState: 'forked', childSessionId: 'child-1' }); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + emit(lifecycle(DelegationUpdateState.Completed, 50, { finishedAt: 3, updatedAt: 50, stage: undefined })); + expect(store.activeSession.toolCalls[0].delegationState).toBe('completed'); + expect(store.activeSession.delegations?.[0]).toMatchObject({ state: 'completed', stage: 'verifying', activity: 'live', sessionSeq: 50 }); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + // A lower-order terminal cannot replace the recovered outcome or its current stage. + emit(lifecycle(DelegationUpdateState.Failed, 49, { finishedAt: 4, updatedAt: 60 })); + expect(store.activeSession.toolCalls[0].delegationState).toBe('completed'); + }); + + it('heals cached Forked50 from same-owner Completed50 idempotently before any future stage', async () => { + const store = await selected(); dispatch(); emit(lifecycle(DelegationUpdateState.Forked, 50, { stage: 'verifying' })); + const recovered = lifecycle(DelegationUpdateState.Completed, 50, { stage: 'verifying', activity: 'live' }); + emit(JSON.parse(JSON.stringify(recovered))); + expect(store.activeSession.toolCalls[0]).toMatchObject({ delegationState: 'completed', childSessionId: 'child-1' }); + expect(store.activeSession.delegations?.[0]).toMatchObject({ state: 'completed', sessionSeq: 50, stage: 'verifying', activity: 'live' }); + const rows = store.activeSession.toolCalls; const observation = store.activeSession.delegations?.[0]; + emit(JSON.parse(JSON.stringify(recovered))); + expect(store.activeSession.toolCalls).toEqual(rows); expect(store.activeSession.delegations?.[0]).toEqual(observation); + expect(store.activeSession.toolCalls).toHaveLength(1); + emit(lifecycle(DelegationUpdateState.Requested, 50, { activity: 'historical', stage: undefined })); + emit(lifecycle(DelegationUpdateState.Failed, 50, { stage: undefined })); + emit(lifecycle(DelegationUpdateState.Failed, 49, { updatedAt: 100 })); + expect(store.activeSession.toolCalls).toEqual(rows); + expect(store.activeSession.delegations?.[0]).toMatchObject({ state: 'completed', sourceEpoch: 'workflow-1', sessionSeq: 50, stage: 'verifying', activity: 'live' }); + expect(store.activeSession.dependencyLabel).toBe('Verifying delegate result'); + }); + + it('uses accepted sequence order consistently instead of a second row timestamp gate', async () => { + const store = await selected(); dispatch(); emit(lifecycle(DelegationUpdateState.Forked, 5)); + emit(lifecycle(DelegationUpdateState.Completed, 6, { updatedAt: 4 })); + expect(store.activeSession.delegations?.[0].state).toBe('completed'); + expect(store.activeSession.toolCalls[0].delegationState).toBe('completed'); + }); + + it.each(['completed', 'in_progress'] as const)('marks absence-only outcome unavailable without fabricating success or running (dispatch %s)', async (status) => { + const store = await selected(); dispatch(); store.activeSession.toolCalls[0].status = status; emit(lifecycle()); + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(idle([], 2)); + await store.refreshSessionRuntime('agent-1', 'session-1'); + expect(store.activeSession.delegations?.[0]).toMatchObject({ state: 'forked', activity: 'historical' }); + expect(getSessionToolPresentation(store.activeSession.toolCalls[0])).toMatchObject({ status: 'pending', statusLabel: 'Status unavailable' }); + expect(store.activeSession.toolCalls[0].delegationState).not.toBe('completed'); + expect(store.activeSession.dependencyLabel).toBeNull(); expect(store.activeRunBusy).toBe(false); + emit(lifecycle(DelegationUpdateState.Completed, 2, { activity: 'live', stage: 'finalizing' })); + expect(store.activeSession.toolCalls[0].delegationState).toBe('completed'); + expect(store.activeSession.delegations?.[0].activity).toBe('historical'); + expect(store.activeSession.dependencyLabel).toBeNull(); + }); + + it('joins a healthy direct read terminal snapshot onto existing rows', async () => { + const store = await selected(); dispatch(); emit(lifecycle()); + mockClient.getSessionRuntimeStatus.mockResolvedValueOnce(idle([lifecycle(DelegationUpdateState.Completed, 3)])); + await store.refreshSessionRuntime('agent-1', 'session-1'); + expect(store.activeSession.toolCalls[0]).toMatchObject({ delegationState: 'completed', childSessionId: 'child-1' }); + }); + + it.each(['load', 'attach'] as const)('preserves nonselected cached terminal truth across %s snapshot replacement', async (operation) => { + const store = await selected(); store.activeSessionId = 'other-session'; store.activeSession.sessionId = 'other-session'; + emit(lifecycle(DelegationUpdateState.Completed, 4)); + mockClient.getSessionRuntimeStatus.mockResolvedValue(idle()); + if (operation === 'load') { + mockClient.loadSession.mockResolvedValueOnce({ response: { configOptions: [], _meta: { + 'querymt/sessionLoadSnapshot.v1': snapshot() + } }, replay: [] }); + await store.loadSession('agent-1', 'session-1'); + } else { + mockClient.attachRemoteSession.mockResolvedValueOnce({ session_id: 'session-1', node_id: 'node-1', attached: true, + config_options: [], snapshot: snapshot() } as unknown as RemoteSessionAttachInfo); + await store.attachRemoteSession('agent-1', 'node-1', 'session-1'); + } + expect(store.activeSession.toolCalls[0]).toMatchObject({ delegationState: 'completed', childSessionId: 'child-1' }); + expect(store.activeRunBusy).toBe(false); + }); + + it('retains lifecycle arriving during load when snapshot history replaces the live candidate', async () => { + const store = await selected(); dispatch(); emit(lifecycle()); + let finish!: (value: Awaited>) => void; + mockClient.loadSession.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const loading = store.loadSession('agent-1', 'session-1'); + await vi.waitFor(() => expect(finish).toBeDefined()); emit(lifecycle(DelegationUpdateState.Completed, 4)); + finish({ response: { configOptions: [], _meta: { 'querymt/sessionLoadSnapshot.v1': snapshot() } }, replay: [] }); + await loading; + expect(store.activeSession.toolCalls[0]).toMatchObject({ delegationState: 'completed', childSessionId: 'child-1' }); + }); + + it('rejects wrong-session activity and old-epoch lifecycle without allowing the row to bypass the cache', async () => { + const store = await selected(); dispatch(); emit(lifecycle()); + emit(lifecycle(DelegationUpdateState.Completed, 10, { sourceEpoch: 'old-workflow', childSessionId: 'wrong-child' })); + announce([lifecycle(DelegationUpdateState.Completed, 11, { sessionId: 'another-session' })]); + emit(lifecycle(DelegationUpdateState.Completed, 12, { toolCallId: 'unrelated-tool' })); + expect(store.activeSession.toolCalls[0]).toMatchObject({ delegationState: 'forked', childSessionId: 'child-1' }); + expect(store.activeSession.delegations?.[0]).toMatchObject({ state: 'forked', toolCallId: 'delegate-1' }); + }); +}); + describe('AgentsStore status consistency', () => { afterEach(cleanup); diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts index 16d55a2b..e8156c33 100644 --- a/src/lib/stores/agents.svelte.ts +++ b/src/lib/stores/agents.svelte.ts @@ -4006,7 +4006,9 @@ export class AgentsStore { private reconcileManagedActivity(agentId: string, sessionId: string, updates: DelegationUpdateNotification[], complete = false, allowNewSource = false) { const key = buildSessionKey(agentId, sessionId); - const merged = mergeDelegationActivity(this.sessionDelegations.get(key) ?? [], updates, complete, allowNewSource); + const matching = updates.filter((item) => item.sessionId === sessionId); + const merged = mergeDelegationActivity(this.sessionDelegations.get(key) ?? [], matching, + complete && matching.length === updates.length, allowNewSource); this.sessionDelegations.set(key, merged); const runtime = this.sessionRuntimeBySession[key]; if (runtime) this.sessionRuntimeBySession = { ...this.sessionRuntimeBySession, [key]: { @@ -4050,9 +4052,11 @@ export class AgentsStore { } private projectSelectedExecution(agentId: string, sessionId: string, runtime = this.sessionRuntimeBySession[buildSessionKey(agentId, sessionId)]) { - if (!runtime || !this.isSelectedSession(agentId, sessionId)) return; + if (!this.isSelectedSession(agentId, sessionId)) return; const key = buildSessionKey(agentId, sessionId); this.activeSession.delegations = this.sessionDelegations.get(key) ?? this.activeSession.delegations; + projectDelegationActivity(this.activeSession); + if (!runtime) return; if (runtime.active_run_id && runtime.phase !== 'idle' && runtime.phase !== 'unknown' && runtime.observation?.availability !== 'unavailable') { for (const tool of this.activeSession.toolCalls) { if (tool.runId === null && (tool.status === 'pending' || tool.status === 'in_progress')) tool.runId = runtime.active_run_id; @@ -5316,12 +5320,16 @@ export class AgentsStore { const params = (notification.params ?? {}) as Parameters[1]; const sessionId = params.sessionId ?? params.session_id; if (sessionId) { - this.rememberSessionChildren(agentId, sessionId, [params.childSessionId ?? params.child_session_id]); - if (params.delegationId) this.reconcileManagedActivity(agentId, sessionId, [params as DelegationUpdateNotification]); - if (this.isSelectedSession(agentId, sessionId)) { - this.activeSession = applyDelegationChildSession(this.activeSession, params); - this.projectSelectedExecution(agentId, sessionId); + if (params.delegationId) { + this.reconcileManagedActivity(agentId, sessionId, [params as DelegationUpdateNotification]); + const accepted = this.sessionDelegations.get(buildSessionKey(agentId, sessionId)) + ?.find((item) => item.delegationId === params.delegationId); + this.rememberSessionChildren(agentId, sessionId, [accepted?.childSessionId]); + } else { + this.rememberSessionChildren(agentId, sessionId, [params.childSessionId ?? params.child_session_id]); + if (this.isSelectedSession(agentId, sessionId)) this.activeSession = applyDelegationChildSession(this.activeSession, params); } + this.projectSelectedExecution(agentId, sessionId); } } if (notification.method === 'querymt/schedules/changed') { From f6455934ce227aaf854375a5ac28d066941b6b73 Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Fri, 30 Oct 2026 19:27:48 +0000 Subject: [PATCH 6/8] test(ui): model idle runtime in pending launch fixture --- .../[agentId]/[sessionId]/__tests__/pending-launch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/sessions/[agentId]/[sessionId]/__tests__/pending-launch.test.ts b/src/routes/sessions/[agentId]/[sessionId]/__tests__/pending-launch.test.ts index de99ea6c..14d02fcf 100644 --- a/src/routes/sessions/[agentId]/[sessionId]/__tests__/pending-launch.test.ts +++ b/src/routes/sessions/[agentId]/[sessionId]/__tests__/pending-launch.test.ts @@ -54,7 +54,7 @@ beforeEach(() => { sessionPinPending: {}, sessionPinErrors: {}, sessionLifecyclePending: {}, sessionLifecycleErrors: {}, sessionConfigPending: {}, promptAttachments: [], composerPrompt: '', error: null, promptFailure: null, sessionHistoryLoading: false, forkPending: false, loading: false, inputSubmitPending: false, - promptRetryPending: false, worktreePending: false, activeSessionAccess: null, activeSessionRuntime: null, + promptRetryPending: false, worktreePending: false, activeSessionAccess: null, activeSessionRuntime: null, activeRunBusy: false, activeInputDelivery: 'steer', activeRecentDelegateModels: {}, activeDelegateAssignments: null, activeDelegateAssignmentsLoading: false, activeDelegateAssignmentsError: null, activeDelegateAssignmentPending: {}, activeDelegateAssignmentConflict: false, promptFocusToken: 0 From 7daa3cfac539caecd1f7cd1ed5dfc81a86c27a0f Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Fri, 30 Oct 2026 19:45:04 +0000 Subject: [PATCH 7/8] fix(ui): send confirmed launch input without runtime evidence --- src/lib/stores/agents.svelte.test.ts | 33 ++++++++++++++++++++++++++++ src/lib/stores/agents.svelte.ts | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/agents.svelte.test.ts b/src/lib/stores/agents.svelte.test.ts index 155b488f..973ed5ee 100644 --- a/src/lib/stores/agents.svelte.test.ts +++ b/src/lib/stores/agents.svelte.test.ts @@ -10767,6 +10767,39 @@ describe('AgentsStore pending launches', () => { finishPrompt({ stopReason: 'end_turn' }); }); + it.each(['unavailable', 'unsupported', 'pending'] as const)('sends a confirmed fresh remote launch once without waiting for %s runtime evidence', async (observation) => { + const store = createStore(); + store.setComposerTarget('node-1'); store.setComposerMode('plan'); + mockClient.supportsQuerymtFeature.mockImplementation((feature) => feature === 'steering'); + if (observation === 'unsupported') mockClient.supportsQuerymtMethod.mockImplementation((method?: string) => method !== 'querymt/session/runtimeState'); + mockClient.getSessionRuntimeStatus.mockImplementation(() => observation === 'pending' ? new Promise(() => {}) : Promise.resolve({ + phase: SessionRuntimePhase.Unknown, active_run_id: null, steerable: false, pending_steering_count: 0, queued_input_count: 0, + observation: { availability: 'unavailable' } + })); + mockClient.createRemoteSession.mockResolvedValueOnce({ session_id: 'remote-1', node_id: 'node-1', attached: true, + config_options: modeOnly('build'), snapshot: { audit: { events: [] } } }); + let finishConfig!: () => void; + let finishPrompt!: (response: PromptResponse) => void; + mockClient.setSessionConfigOption.mockImplementationOnce(() => new Promise((resolve) => { finishConfig = () => resolve(modeOnly('plan')); })); + mockClient.sendPrompt.mockImplementationOnce(() => new Promise((resolve) => { finishPrompt = resolve; })); + const launchId = store.beginLaunch('agent-1')!; + await vi.waitFor(() => expect(finishConfig).toBeTypeOf('function')); + expect(store.pendingLaunches[launchId]).toMatchObject({ sessionId: 'remote-1', status: 'creating' }); + expect(store.activeSessionRuntime).toMatchObject({ phase: 'unknown', observation: { availability: 'unavailable' } }); + expect(mockClient.sendPrompt).not.toHaveBeenCalled(); + const initialReads = mockClient.getSessionRuntimeStatus.mock.calls.length; + finishConfig(); + await vi.waitFor(() => expect(mockClient.sendPrompt).toHaveBeenCalledOnce()); + expect(mockClient.sendPrompt).toHaveBeenCalledWith('remote-1', 'Fix the failing tests', [], expect.anything()); + expect(mockClient.createRemoteSession).toHaveBeenCalledOnce(); + expect(store.pendingLaunches[launchId].status).toBe('ready'); + expect(store.isLaunchSettingUp('agent-1', 'remote-1')).toBe(false); + expect(store.activeSessionRuntime?.phase).toBe('unknown'); // No fabricated Idle observation. + expect(mockClient.getSessionRuntimeStatus).toHaveBeenCalledTimes(initialReads); + expect(mockClient.steerSession).not.toHaveBeenCalled(); expect(mockClient.queueSession).not.toHaveBeenCalled(); + finishPrompt({ stopReason: 'end_turn' }); + }); + it('keeps creating and sends the prompt after the user navigates away', async () => { const store = createStore(); let finish!: (response: { sessionId: string; configOptions: SessionConfigOption[] }) => void; diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts index e8156c33..cc796d58 100644 --- a/src/lib/stores/agents.svelte.ts +++ b/src/lib/stores/agents.svelte.ts @@ -3649,7 +3649,7 @@ export class AgentsStore { } if (canControlRun && !runtime && !explicit) return; const observation = runtime ?? this.activeSessionRuntime; - if (observation?.phase === 'unknown' || observation?.observation?.availability === 'unavailable') { + if (!explicit && (observation?.phase === 'unknown' || observation?.observation?.availability === 'unavailable')) { this.error = 'Execution status unavailable. Reconnect or refresh before sending.'; return; } From 5489031df9ce51a4545ac7b68f62c12866c3bf3a Mon Sep 17 00:00:00 2001 From: Ivan Zatevakhin Date: Fri, 30 Oct 2026 20:12:20 +0000 Subject: [PATCH 8/8] fix(ui): preserve historical tool run ownership --- src/lib/domain/session-snapshot.test.ts | 30 +++++++++++++++++++++++++ src/lib/domain/session-snapshot.ts | 16 ++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/lib/domain/session-snapshot.test.ts b/src/lib/domain/session-snapshot.test.ts index 6c0e9855..d7b9fdb8 100644 --- a/src/lib/domain/session-snapshot.test.ts +++ b/src/lib/domain/session-snapshot.test.ts @@ -898,6 +898,36 @@ describe('activeSessionFromLoadResponse', () => { expect(normalizeHistoricalSession(session, { loadCompleted: true }).runState).toBe(runState); }); + it.each([ + { name: 'unfinished A while B is active', finishA: false, startBTool: false, finishB: false, lateA: false }, + { name: 'unfinished A after B completes', finishA: false, startBTool: false, finishB: true, lateA: false }, + { name: 'current B tool despite a late A terminal', finishA: false, startBTool: true, finishB: false, lateA: true }, + { name: 'completed A and current B tool', finishA: true, startBTool: true, finishB: false, lateA: false } + ])('preserves historical tool run ownership: $name', ({ finishA, startBTool, finishB, lateA }) => { + const events = [ + { kind: { type: 'run_started', data: { run_id: 'run-A' } } }, + { kind: { type: 'tool_call_start', data: { tool_call_id: 'tool-A', tool_name: 'read_tool' } } }, + ...(finishA ? [{ kind: { type: 'tool_call_end', data: { tool_call_id: 'tool-A', result: 'A finished', is_error: false } } }] : []), + { kind: { type: 'run_started', data: { run_id: 'run-B' } } }, + ...(startBTool ? [{ kind: { type: 'tool_call_start', data: { tool_call_id: 'tool-B', tool_name: 'search_text' } } }] : []), + ...(lateA ? [{ kind: { type: 'run_completed', data: { run_id: 'run-A', outcome: 'completed' } } }] : []), + ...(finishB ? [{ kind: { type: 'run_completed', data: { run_id: 'run-B', outcome: 'completed' } } }] : []) + ].map((event, index) => ({ ...event, seq: index + 1 })); + const session = activeSessionFromLoadResponse('session-runs', { + _meta: { 'querymt/sessionLoadSnapshot.v1': { audit: { events } } } + }); + + expect(session.runId).toBe('run-B'); + expect(session.runState).toBe(finishB ? 'completed' : startBTool ? 'tool-running' : 'thinking'); + expect(session.activeToolCallId).toBe(startBTool ? 'tool-B' : null); + expect(session.toolCalls[0]).toMatchObject({ + id: 'tool-A', runId: 'run-A', status: finishA ? 'completed' : 'failed', + result: finishA ? 'A finished' : 'Run ended before a tool result was recorded.' + }); + if (!finishA) expect(session.toolCalls[0].isError).toBe(true); + if (startBTool) expect(session.toolCalls[1]).toMatchObject({ id: 'tool-B', runId: 'run-B', status: 'in_progress' }); + }); + it('marks interrupted tools failed only when terminal lifecycle is known', () => { const session: ActiveSessionViewModel = { sessionId: 'session-lifecycle', diff --git a/src/lib/domain/session-snapshot.ts b/src/lib/domain/session-snapshot.ts index 141045d9..3d3d7141 100644 --- a/src/lib/domain/session-snapshot.ts +++ b/src/lib/domain/session-snapshot.ts @@ -87,6 +87,7 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno session.usage.accounting = readAccountingSnapshot(snapshot.accounting); const toolCallsById = new Map(); + const supersededRunIds = new Set(); const pendingDelegationToolCalls = new Map(); const pendingChildSessionsByDelegation = new Map(); const structuredPrompts = indexStructuredPrompts(snapshot); @@ -131,7 +132,9 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno } if (kind === 'run_started') { - session.runId = readString(data.run_id) ?? null; + const runId = readString(data.run_id) ?? null; + if (session.runId && runId && session.runId !== runId) supersededRunIds.add(session.runId); + session.runId = runId; session.runState = 'thinking'; session.runStateFromLifecycle = true; session.activityLabel = 'Agent is working…'; @@ -305,7 +308,11 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno restoreUnmatchedPrompts(session, snapshot, structuredPrompts, restoredStructuredPrompts, toolCallsById); - session.toolCalls = Array.from(toolCallsById.values()); + // A later own run proves the earlier run ended, but not that its tools succeeded. + session.toolCalls = Array.from(toolCallsById.values(), (tool) => + tool.runId && supersededRunIds.has(tool.runId) && (tool.status === 'pending' || tool.status === 'in_progress') + ? { ...tool, status: 'failed' as const, isError: true, result: tool.result ?? 'Run ended before a tool result was recorded.' } + : tool); return (snapshot.delegationUpdates ?? []).reduce(applyDelegationChildSession, normalizeHistoricalSession(session)); } @@ -414,7 +421,9 @@ export function normalizeHistoricalSession( : tool); return session; } - const activeTool = session.toolCalls.find((tool) => tool.status === 'in_progress' || tool.status === 'pending'); + const activeTool = session.toolCalls.find((tool) => + (tool.status === 'in_progress' || tool.status === 'pending') && + (!session.runId || !tool.runId || tool.runId === session.runId)); if (activeTool) { session.runState = 'tool-running'; session.activeToolCallId = activeTool.id; @@ -641,6 +650,7 @@ function mergeHistoricalToolCall( arguments: update.arguments ?? existing?.arguments ?? null, result: update.result ?? existing?.result ?? null, isError: update.isError ?? existing?.isError, + runId: update.runId ?? existing?.runId ?? null, eventIndex: mergeEventIndex(existing?.eventIndex, update.eventIndex), childSessionId: update.childSessionId ?? existing?.childSessionId ?? null });