diff --git a/docs/design/web-shell/session-active-work-live-state.md b/docs/design/web-shell/session-active-work-live-state.md new file mode 100644 index 00000000000..c63612a099f --- /dev/null +++ b/docs/design/web-shell/session-active-work-live-state.md @@ -0,0 +1,38 @@ +# Session active-work live state + +## Problem + +The workspace session snapshot exposes only foreground prompt activity. Once a +prompt launches background work and settles, the sidebar cannot distinguish the +still-working Session from an idle one even though the bridge already tracks +per-session active-work holds. + +## Contract + +Add one optional `activeWorkState` field to session summaries and workspace +live-state rows: + +- `active`: daemon-owned work exists or a fresh child snapshot contains a hold; +- `idle`: a fresh child snapshot covers every required category and is empty; +- `unknown`: reporting was negotiated but is stale or incomplete; +- `unsupported`: the child did not negotiate active-work reporting. + +`hasActivePrompt` keeps its running-foreground-turn meaning. The Web Shell +renders `activeWorkState: active` separately when no foreground prompt is +running; this state can represent queued prompt work as well as background +work. + +The floating Todo panel animates an `in_progress` item only while the local +stream, daemon foreground state, or per-session active-work state confirms +that execution is live. A persisted `in_progress` value without live activity +keeps its static status glyph instead of implying that work is still running. + +The field is optional for compatibility with older daemons. It uses the +bridge's existing hold cache, capability negotiation, and freshness window, so +the live-state request remains an in-memory read with no ACP round trip. + +## Scope + +This change exposes known liveness and does not add task persistence, route +rebinding, or cross-runtime recovery. Those require a reproduced routing loss, +not only an idle-looking UI. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index a741f08e107..cecfc222c9c 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -2539,7 +2539,7 @@ Additional fields may appear on each session when `view=organized`: } ``` -Trusted active lists include live daemon overlay fields such as `clientCount` and `hasActivePrompt`. Untrusted-secondary and archived lists are storage-only: live overlay fields remain absent or false, and archived entries set `isArchived` to `true`. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. +Trusted active lists include live daemon overlay fields such as `clientCount`, `hasActivePrompt`, and `activeWorkState`. Untrusted-secondary and archived lists are storage-only: live overlay fields remain absent or false, and archived entries set `isArchived` to `true`. Empty array (not 404) when no sessions exist — a session-picker UI shouldn't error just because the workspace is idle. ### `GET /workspaces/:workspace/sessions/live-state` @@ -2559,6 +2559,7 @@ Response: "sessionId": "session-123", "clientCount": 1, "hasActivePrompt": true, + "activeWorkState": "active", "isWaitingForPermission": false, "isWaitingForUserQuestion": false, "updatedAt": "2026-08-18T08:12:30.123Z" @@ -2567,7 +2568,7 @@ Response: } ``` -`v` is the response schema version. Every successful response includes `Cache-Control: no-store`. `sessions` is the complete, unpaginated, unordered set of sessions currently live in the selected runtime; an empty live runtime returns `200` with `sessions: []`. `clientCount`, `hasActivePrompt`, `isWaitingForPermission`, and `isWaitingForUserQuestion` are required wire fields, and missing optional bridge values project to `0` or `false`. Static catalog fields such as display name, creation time, organization, and source metadata are deliberately excluded and remain owned by the full catalog. An absent live-state row only clears a known catalog row's volatile fields; it never deletes a persisted catalog row. +`v` is the response schema version. Every successful response includes `Cache-Control: no-store`. `sessions` is the complete, unpaginated, unordered set of sessions currently live in the selected runtime; an empty live runtime returns `200` with `sessions: []`. `clientCount`, `hasActivePrompt`, `isWaitingForPermission`, and `isWaitingForUserQuestion` are required wire fields, and missing optional bridge values project to `0` or `false`. `activeWorkState` is wire-additive and absent on older daemons: `active` means the daemon owns unsettled work or the child sent a fresh non-empty hold snapshot; `idle` is emitted only for a fresh empty snapshot covering every required category; `unknown` means negotiated reporting is stale or incomplete; and `unsupported` means the child did not negotiate reporting. It does not change `hasActivePrompt`: a background shell, cron turn, or pending terminal notification is active work without becoming a foreground prompt. Static catalog fields such as display name, creation time, organization, and source metadata are deliberately excluded and remain owned by the full catalog. An absent live-state row only clears a known catalog row's volatile fields; it never deletes a persisted catalog row. `updatedAt` is an optional daemon-observed activity watermark, present when a prompt that reached the running state has published a formal terminal in the current bridge. It advances exactly once per such terminal — success, error, cancellation, and deadline alike — is written before the terminal event is published, and is strictly increasing per live session even when two terminals land in one wall-clock millisecond or the wall clock moves backward; a forward clock jump therefore persists until wall time catches up. It is never earlier than the session's `createdAt`: the first advance floors at creation time, so a wall-clock rollback between creation and the first terminal cannot key a row behind the `createdAt` it was already listed at. Prompt admission, queue waits, streamed updates, queue-only cancellation, heartbeats, and interaction waits never advance it. Clients use it to refresh the recency of a catalog row they already hold instead of reloading the full catalog after a completed turn. It is not a persistence acknowledgement: the recorder writes turn results asynchronously, so the value proves only that the daemon observed a running attempt settle. It is absent before the first running terminal in a bridge generation — including for a session restored from disk — so absence is not a support probe, and it disappears when a daemon restart or workspace runtime replacement installs a new bridge. When both a live and a persisted summary exist for one session, full catalog responses report the later valid timestamp, so `GET /session/:id/status`, which returns the bridge summary directly without that merge, may report an earlier value than a list response. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 27a618f585d..cac55089cd5 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -436,12 +436,18 @@ describe('createAcpSessionBridge', () => { // than idle, and graded `partial` — the channel did negotiate, it just // has not spoken yet, which is not the same as `none`. expect(bridge.activeWork).toBe(true); + expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe( + 'unknown', + ); expect(reportingGrade(bridge)).toBe('partial'); await sendActiveWorkSnapshot(handle, 1, [ { sessionId: session.sessionId, holds: [] }, ]); expect(bridge.activeWork).toBe(false); + expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe( + 'idle', + ); expect(reportingGrade(bridge)).toBe('full'); // Prompts are a daemon-owned fact: no child report is involved. @@ -450,6 +456,9 @@ describe('createAcpSessionBridge', () => { prompt: [{ type: 'text', text: 'start background work' }], }); expect(bridge.activeWork).toBe(true); + expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe( + 'active', + ); prompt.resolve({ stopReason: 'end_turn' }); await running; expect(bridge.activeWork).toBe(false); @@ -465,6 +474,9 @@ describe('createAcpSessionBridge', () => { // Unsupported must not behave like unknown: an older child would // otherwise pin every session as permanently busy and unreapable. expect(bridge.activeWork).toBe(false); + expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe( + 'unsupported', + ); expect(reportingGrade(bridge)).toBe('none'); expect(bridge.activeWorkCoverage.oldestCoveredReportAt).toBeNull(); @@ -474,6 +486,39 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('applies a snapshot received before session registration', async () => { + const newSessionStarted = deferred(); + const releaseNewSession = deferred(); + const handle = makeChannel({ + initializeImpl: () => activeWorkInitializeResponse(), + newSessionImpl: async () => { + newSessionStarted.resolve(); + await releaseNewSession.promise; + return { sessionId: 'registering' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const spawning = bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await newSessionStarted.promise; + await sendActiveWorkSnapshot(handle, 1, [ + { sessionId: 'registering', holds: [agentHold('a1')] }, + ]); + releaseNewSession.resolve(); + const session = await spawning; + + expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe( + 'active', + ); + await sendActiveWorkSnapshot(handle, 2, [ + { sessionId: session.sessionId, holds: [] }, + ]); + expect(bridge.getSessionSummary(session.sessionId).activeWorkState).toBe( + 'idle', + ); + + await bridge.shutdown(); + }); + it('retains sessions reported by a negotiated but incomplete child', async () => { let conditionalCloseCalls = 0; let forcedCloseCalls = 0; diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index a53dbc1272b..fe480029d1f 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1104,6 +1104,11 @@ interface ChannelInfo { categories: readonly ActiveWorkHoldCategory[]; /** Highest snapshot sequence applied; guards against reordering only. */ seq: number; + /** Latest report, retained for Sessions registered after it arrived. */ + snapshot?: { + receivedAt: number; + sessions: Map>; + }; }; channelLiveness?: ChannelLivenessMonitor; handshakeComplete: boolean; @@ -3162,6 +3167,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); } + function entryActiveWorkState( + entry: SessionEntry, + ): NonNullable { + if (entryHasLocalWork(entry) || childReportsHeldWork(entry)) { + return 'active'; + } + const capability = channelInfoForEntry(entry)?.activeWork; + if (!capability) return 'unsupported'; + if ( + childWorkIsUnknown(entry) || + ACTIVE_WORK_HOLD_CATEGORIES.some( + (category) => !capability.categories.includes(category), + ) + ) { + return 'unknown'; + } + return 'idle'; + } + /** * The guards every automatic teardown shares, whichever policy decided it * was time to look. Each caller adds its own policy on top (the reaper its @@ -3573,6 +3597,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { for (const hold of session.holds) holds.set(hold.id, hold.category); reported.set(session.sessionId, holds); } + info.activeWork.snapshot = { receivedAt: now, sessions: reported }; // Iterate what the channel owns rather than what the snapshot named: a // Session the child did not mention holds nothing on the child side. // Because reports are complete, silence about a Session this channel owns @@ -4234,6 +4259,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}), clientCount: entry.clientIds.size, hasActivePrompt: hasInFlightPromptActivity(entry), + activeWorkState: entryActiveWorkState(entry), isWaitingForPermission, isWaitingForUserQuestion, pendingInteractionCount: entry.pendingInteractions.size, @@ -7027,6 +7053,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { branch?: { name: string; baseBranch: string }; } = {}, ): SessionEntry => { + const childSnapshot = ci.activeWork?.snapshot; + const reportedChildHolds = childSnapshot?.sessions.get(sessionId); const entry: SessionEntry = { sessionId, workspaceCwd, @@ -7084,8 +7112,11 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attachRefs: new Map(), spawnOwnerWantedKill: false, promptActive: false, - childHolds: null, - childHoldsAt: null, + childHolds: reportedChildHolds ?? null, + childHoldsAt: + childSnapshot && reportedChildHolds !== undefined + ? childSnapshot.receivedAt + : null, activeWorkCloseInFlight: false, activeWorkCloseFailures: 0, activeWorkCloseRetryAt: null, diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 8675b4d9d5d..c191e78defa 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -2210,14 +2210,15 @@ export class BridgeClient implements Client { if (method === ACTIVE_WORK_NOTIFICATION_METHOD) { const snapshot = parseActiveWorkSnapshot(params); if (snapshot) { - // Sessions the child claims but this channel does not own are dropped - // rather than rejecting the whole snapshot: the rest of it is still - // usable, and a channel must never influence another channel's state. + // Retain rows while a Session is registering so the bridge can apply + // a report that races the newSession response. this.onActiveWork?.({ v: ACTIVE_WORK_HEARTBEAT_VERSION, seq: snapshot.seq, - sessions: snapshot.sessions.filter((session) => - this.ownsSession(session.sessionId), + sessions: snapshot.sessions.filter( + (session) => + this.ownsSession(session.sessionId) || + this.hasSessionSpawnInFlight(), ), }); } diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 1cc88494ce2..f9163cadd5c 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -814,6 +814,9 @@ export interface BridgeSessionSummary { sourceId?: string; clientCount: number; hasActivePrompt: boolean; + /** Per-session active-work observation. `idle` is emitted only from a + * fresh snapshot that covers every negotiated hold category. */ + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; /** True while a non-question permission request awaits a response. */ isWaitingForPermission?: boolean; /** True while an ask_user_question request awaits a response. */ diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 4b108635928..daa01a8074a 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -2343,6 +2343,9 @@ export class AcpDispatcher { ...(s.sourceId !== undefined ? { sourceId: s.sourceId } : {}), clientCount: s.clientCount, hasActivePrompt: s.hasActivePrompt, + ...(s.activeWorkState !== undefined + ? { activeWorkState: s.activeWorkState } + : {}), isArchived: s.isArchived === true, ...(s.isPinned !== undefined ? { isPinned: s.isPinned } : {}), ...(s.pinnedAt !== undefined ? { pinnedAt: s.pinnedAt } : {}), diff --git a/packages/cli/src/serve/conversations/standalone-session-service.ts b/packages/cli/src/serve/conversations/standalone-session-service.ts index 74b2d90eb28..e2f43f14ff6 100644 --- a/packages/cli/src/serve/conversations/standalone-session-service.ts +++ b/packages/cli/src/serve/conversations/standalone-session-service.ts @@ -423,6 +423,9 @@ function mergeLiveStandaloneSummary( updatedAt: laterTimestamp(live.updatedAt, persisted.updatedAt), clientCount: live.clientCount, hasActivePrompt: live.hasActivePrompt, + ...(live.activeWorkState !== undefined + ? { activeWorkState: live.activeWorkState } + : {}), ...(live.isWaitingForPermission !== undefined ? { isWaitingForPermission: live.isWaitingForPermission } : {}), diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index df8da1dd38e..2a2ecdce934 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -8490,6 +8490,9 @@ export function registerSessionRoutes( sessionId: session.sessionId, clientCount: session.clientCount, hasActivePrompt: session.hasActivePrompt, + ...(session.activeWorkState !== undefined + ? { activeWorkState: session.activeWorkState } + : {}), isWaitingForPermission: session.isWaitingForPermission ?? false, isWaitingForUserQuestion: session.isWaitingForUserQuestion ?? false, // Bridge-local activity watermark, absent until a running prompt in diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 519f6befeb2..99beb77f56f 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1373,6 +1373,8 @@ export interface DaemonSessionSummary { sourceId?: string; clientCount?: number; hasActivePrompt?: boolean; + /** Per-session active-work observation from the owning runtime. */ + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; isWaitingForPermission?: boolean; isWaitingForUserQuestion?: boolean; pendingInteractionCount?: number; @@ -1597,6 +1599,8 @@ export interface DaemonSessionLiveState { sessionId: string; clientCount: number; hasActivePrompt: boolean; + /** Absent when talking to an older daemon. */ + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; isWaitingForPermission: boolean; isWaitingForUserQuestion: boolean; /** diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 375945f25d2..da0f2be4829 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -581,6 +581,7 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { sessionId: string; clientCount: number; hasActivePrompt: boolean; + activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported'; isWaitingForPermission: boolean; isWaitingForUserQuestion: boolean; updatedAt?: string; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 6da36418ec1..bdc62923bb7 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -246,7 +246,7 @@ const { mockReleaseDetachedWebTerminal, mockReleaseWebTerminal, mockUseWorkspaceSessionLiveState, - mockUseDaemonActivePromptBridge, + mockUseDaemonSessionActivityBridge, } = vi.hoisted(() => { const connection: MockConnection = { status: 'connected', @@ -717,7 +717,7 @@ const { mockReleaseWebTerminal: vi.fn(), mockReleaseDetachedWebTerminal: vi.fn(), mockUseWorkspaceSessionLiveState: vi.fn(() => new Map()), - mockUseDaemonActivePromptBridge: vi.fn(), + mockUseDaemonSessionActivityBridge: vi.fn(), }; }); @@ -1614,7 +1614,7 @@ vi.mock('./session-catalog/session-catalog-hooks', () => ({ hasActivePrompt: testState.sessionHasActivePrompt, authoritative: true, }), - useDaemonActivePromptBridge: mockUseDaemonActivePromptBridge, + useDaemonSessionActivityBridge: mockUseDaemonSessionActivityBridge, // The Workspaces overview panel's per-row session counts; inert here. useSessionCatalogQuery: () => ({ page: undefined, @@ -9381,10 +9381,11 @@ beforeEach(() => { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], }; mockUseWorkspaceSessionLiveState.mockClear(); - mockUseDaemonActivePromptBridge.mockReset(); - mockUseDaemonActivePromptBridge.mockImplementation( - () => testState.sessionHasActivePrompt, - ); + mockUseDaemonSessionActivityBridge.mockReset(); + mockUseDaemonSessionActivityBridge.mockImplementation(() => ({ + hasActivePrompt: testState.sessionHasActivePrompt, + activeWorkState: undefined, + })); mockWorkspace.status = 'connected'; mockWorkspace.refreshCapabilities.mockReset(); mockWorkspace.refreshCapabilities.mockResolvedValue( @@ -11022,7 +11023,7 @@ describe('App conversation indicator keep-alive (#9487)', () => { renderApp({ sidebar: false }); await flush(); - expect(mockUseDaemonActivePromptBridge).toHaveBeenCalledWith( + expect(mockUseDaemonSessionActivityBridge).toHaveBeenCalledWith( mockWorkspace.client, '/tmp/live', 'session-1', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 4588a033da8..ed42d0ae114 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -114,7 +114,7 @@ import { import { useVoiceWorkspaceSettings } from './voice/use-voice-workspace-settings'; import { useSessionCatalogController, - useDaemonActivePromptBridge, + useDaemonSessionActivityBridge, } from './session-catalog/session-catalog-hooks'; import { loadSessionCatalogOnce, @@ -3296,7 +3296,10 @@ export function App({ ? trustedLiveWorkspaces[0]?.cwd : undefined : connection.workspaceCwd; - const sessionHasActivePrompt = useDaemonActivePromptBridge( + const { + hasActivePrompt: sessionHasActivePrompt, + activeWorkState: sessionActiveWorkState, + } = useDaemonSessionActivityBridge( workspace.client, activePromptWorkspaceCwd, connection.sessionId, @@ -17988,6 +17991,11 @@ export function App({ void; } @@ -28,6 +29,7 @@ export const TodoPanel = memo(function TodoPanel({ todos, title, statusItems = [], + hasLiveActivity = true, onOpen, }: TodoPanelProps) { const { t } = useI18n(); @@ -138,7 +140,7 @@ export const TodoPanel = memo(function TodoPanel({ className={`${styles.item} ${getStatusClass(todo.status)}`} >