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..9382e7b8 100644
--- a/src/lib/components/session/SessionActivityBar.svelte
+++ b/src/lib/components/session/SessionActivityBar.svelte
@@ -1,5 +1,6 @@
{#if isVisible}
-
-
- {#if isBusy}
-
- {:else if session.runState === 'waiting-input'}
-
- {:else}
-
+
+
+
+ {#snippet child({ props })}
+
+
+
+ {#if isBusy}
+
+ {:else if session.runState === 'waiting-input'}
+
+ {:else}
+
+ {/if}
+
+ {primaryStatus}
+ {#if session.runState === 'failed'}
+ Review the error, update the prompt, or refresh.
+ {:else if session.runState === 'waiting-input'}
+ Respond below to continue.
+ {/if}
+
+ {/snippet}
+
+ {#if canCancel}
+
+ Double Esc to cancel
+
{/if}
-
- {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 !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 19eef4af..6b58cada 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' || (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.runState === 'waiting-input' || summaryStatus === 'waiting') {
+ 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 === '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/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/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..d7b9fdb8 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,37 @@ describe('activeSessionFromLoadResponse', () => {
expect(normalizeHistoricalSession(session, { loadCompleted: true }).runState).toBe(runState);
});
- it('finalizes orphaned tools without overwriting explicit lifecycle state', () => {
+ 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',
transcript: [],
@@ -943,10 +973,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 +1016,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..3d3d7141 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();
@@ -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,6 +132,9 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno
}
if (kind === 'run_started') {
+ 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…';
@@ -139,6 +143,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 +260,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 +308,18 @@ export function activeSessionFromLoadResponse(sessionId: string, response: unkno
restoreUnmatchedPrompts(session, snapshot, structuredPrompts, restoredStructuredPrompts, toolCallsById);
- session.toolCalls = finalizeHistoricalToolCalls(Array.from(toolCallsById.values()), snapshot.audit?.events ?? []);
+ // 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));
}
+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 +410,31 @@ 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') &&
+ (!session.runId || !tool.runId || tool.runId === session.runId));
+ 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;
}
@@ -644,40 +650,13 @@ 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
});
}
-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-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 9b2d926d..a74850cd 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,9 @@ import {
createEmptyActiveSession,
endSessionWork,
getNextConversationEventIndex,
- mergeMissingSessionUsage
+ mergeMissingSessionUsage,
+ projectSessionRuntime,
+ projectDelegationActivity
} from './session-updates';
function notification(update: SessionNotification['update']): SessionNotification {
@@ -34,6 +38,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 +352,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',
@@ -407,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 5e2ed5fb..f0430010 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,
@@ -76,6 +77,7 @@ export function appendSessionReplay(
nextEventIndex = currentConversationEventIndex(session);
}
+ projectDelegationActivity(session);
return session;
}
@@ -193,7 +195,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 +210,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 +220,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 +235,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 +287,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 +450,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 +464,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 +474,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 +488,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':
@@ -571,9 +525,174 @@ export function applySessionNotification(
break;
}
+ projectDelegationActivity(next);
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 && (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;
+ 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) {
+ 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;
+}
+
+function isTerminalDelegation(state: string | undefined): boolean {
+ return state === 'completed' || state === 'failed' || state === 'cancelled';
+}
+
+export function projectDelegationActivity(session: ActiveSessionViewModel): void {
+ 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'
+ : 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 +720,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 +949,25 @@ 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) {
+ 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/sessions.test.ts b/src/lib/domain/sessions.test.ts
index 75f50240..17cb99e7 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,41 @@ describe('workspace location', () => {
});
describe('inferSessionStatus', () => {
- it('maps active runtime phases to thinking', () => {
+ 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(
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 +323,7 @@ describe('inferSessionStatus', () => {
runtimeStatus: runtimeMeta('waiting')
})
)
- ).toBe('waiting');
+ ).toBe('thinking');
});
it('maps cancel_requested runtimeStatus to cancelling', () => {
@@ -344,8 +365,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..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.
@@ -221,42 +222,45 @@ 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.hasUserHistory);
+}
+
+export function runtimeSessionStatus(runtime: QuerymtSessionMeta['runtimeStatus'], hasHistory = false): 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';
}
}
-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 08bc07df..3f31097e 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'
@@ -141,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. */
@@ -317,6 +320,7 @@ export interface SessionTranscriptGroup {
export interface SessionToolCallItem {
id: string;
+ runId?: string | null;
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
kind: string | null;
@@ -329,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 {
@@ -386,6 +392,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..973ed5ee 100644
--- a/src/lib/stores/agents.svelte.test.ts
+++ b/src/lib/stores/agents.svelte.test.ts
@@ -2,6 +2,11 @@ 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 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';
import { findModeConfigOption, findReasoningConfigOption, getModelSelectionKey } from '$lib/querymt/config-options';
@@ -9,6 +14,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,
@@ -497,6 +503,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' };
@@ -2569,7 +2787,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 +6083,876 @@ 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);
+
+ 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: {
+ 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' }));
+ 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 +8462,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');
});
@@ -9677,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 84b272a9..cc796d58 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,41 @@ 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)];
+ 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)
.filter((session) => incomingKeys.has(getSessionKey(session)));
@@ -954,12 +1006,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 +3622,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 +3648,11 @@ export class AgentsStore {
return;
}
if (canControlRun && !runtime && !explicit) return;
+ const observation = runtime ?? this.activeSessionRuntime;
+ if (!explicit && (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 +3664,8 @@ export class AgentsStore {
prompt,
attachments,
clientPromptId,
- imageMode
+ imageMode,
+ ownsAction
);
return;
}
@@ -3640,13 +3700,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 +3721,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 +3796,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 +3814,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 +3825,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 +3837,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 +3855,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 +3871,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 +3996,82 @@ 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 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]: {
+ ...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 patch = (session: DesktopSessionSummary) => session.agentId === agentId && session.sessionId === sessionId
+ ? { ...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) };
+ 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 (!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;
+ }
+ }
+ 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 +4082,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 +4096,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 +4210,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';
}
}
@@ -4074,13 +4258,24 @@ 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;
}
}
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 +4288,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 +4545,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 +4556,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 +4578,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 +5236,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 +5280,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);
}
@@ -5087,10 +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 (this.isSelectedSession(agentId, sessionId)) {
- this.activeSession = applyDelegationChildSession(this.activeSession, params);
+ 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') {
@@ -5114,7 +5353,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') {
@@ -5446,12 +5685,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]: {
@@ -5529,35 +5769,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. */
@@ -5569,33 +5815,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 ?? '',
@@ -5610,12 +5876,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 }
@@ -5625,8 +5899,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;
}
@@ -6346,15 +6620,20 @@ 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 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 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';
@@ -6371,18 +6650,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;
});
@@ -6393,7 +6672,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))) {
@@ -6499,6 +6778,18 @@ 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);
+ // 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 (notification.update.sessionUpdate === 'config_option_update') {
const key = buildSessionKey(agentId, notification.sessionId);
// Authoritative updates may carry only the options relevant to the
@@ -7057,6 +7348,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);
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(
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