* feat(web-shell): branch from completed assistant responses Add durable response checkpoints so Web Shell sessions can branch from eligible completed Assistant turns without mutating the source history. - Record and validate checkpoints behind serialized topology fences - Preserve historical anchors through replay, daemon, SDK, and UI layers - Publish bounded forks with crash-safe ownership and referenced backups - Serialize prompt, rewind, branch, automatic turn, and close mutations - Cover stale anchors, replay pagination, cleanup, and pending UI states Note: Responses recorded before this change remain non-branchable. # Conflicts: # packages/acp-bridge/src/bridge.ts # packages/acp-bridge/src/bridgeTypes.ts # packages/cli/src/acp-integration/acpAgent.test.ts # packages/cli/src/acp-integration/acpAgent.ts # packages/cli/src/serve/routes/session.ts # packages/cli/src/serve/server.test.ts # packages/core/src/services/chatRecordingService.ts # packages/core/src/services/sessionService.test.ts # packages/core/src/services/sessionService.ts # packages/sdk-typescript/src/daemon/DaemonClient.ts # packages/web-shell/client/components/MessageItem.tsx # packages/web-shell/client/components/MessageList.tsx * fix(session): preserve historical branch checkpoints Keep Assistant-response branching intact across the daemon stack after rebases, including history serialization and persisted-session ownership. - Forward durable checkpoint IDs through Bridge, SDK, and UI layers - Serialize live history mutations and retain valid nested branch anchors - Preserve persisted branches during generation cleanup - Add cross-layer regression tests for replay and stale checkpoints * fix(web-shell): harden response session branching * chore: remove PR comment evaluation artifact Keep the PR review report as a local ignored backup instead of shipping it with the feature branch. - Remove the generated PR comment evaluation from tracked files - Preserve the report under the ignored analyze directory * fix(web-shell): guard historical branch mutations Historical branch requests could outlive the client timeout during an active turn, and interactive forks lacked the recorder's cross-process writer-lease barrier. - Hide Assistant Branch actions while a turn is active - Run interactive fork creation inside the recorder write barrier - Use the concrete checkpoint recorder contract in Session - Document committed-session ownership and implemented design status * perf(core): index historical branch points during transcript scan Build branch catalogs during the frozen index scan so the first history page no longer reopens and materializes the complete active chain. - Retain a compact projection for shared branch-point resolution - Correlate live branch anchors with the completed prompt and final reply - Complete recorder mocks required by the concrete Session contract - Update the reviewed design with performance and correlation invariants * fix(core): address review findings — dead code, boundary remap, promptId guard, stale toast (#8274) * fix(core): address review findings — dead code, boundary remap, promptId guard, stale toast (#8274) * fix(core): address review findings — archived GC, subtype registration, UUID validation, dead code (#8274) * test: strengthen branch-point and fork coverage from review (#8274) Add focused tests requested in PR review: - branch catalog resolves checkpoints that fall on a later page - accept a parallel tool batch closed within a single turn - exercise the linkSync->copyFileSync fork backup fallback success path - prove a remapped checkpoint stays usable via a nested fork - isolate each branch-point validation conjunct across bridge and SDK * fix: address round-4 review feedback for session branching (#8274) - Make the directory-fsync durability test platform-aware (skip on win32), since fsyncDirectoryBestEffort swallows the injected error on Windows and the rejection path is non-Windows by design. - Reject atRecordId on the side-task fork path instead of silently discarding it, so the API surface no longer implies acceptance. - Correct the design doc: name the real promptQueue FIFO (not the nonexistent historyMutationQueue) and describe filtered checkpoint boundaries as remapped to the nearest retained predecessor, not unconditionally null. - Add focused tests: branch-point assistantRecordUuid mismatch rejection, and insight-block branchRecordId anchoring (insight-only block must not anchor onto the previous reply). * fix: address round-5 review feedback for session branching (#8274) * fix: address round-6 review feedback for session branching (#8274) * fix: address round-7 review feedback for session branching (#8274) * fix(core): harden branch-point resolution against malformed transcript shapes (#8274) - Filter null/non-object part elements in the shared branch resolver so a transcript containing null parts no longer makes forkSession throw a TypeError for every checkpoint. - Tag tool calls carried in from the pre-boundary prefix so a dangling call left by a crashed turn no longer permanently disables checkpoint recording; only calls issued inside the turn must close. - Merge duplicate-uuid records first-wins for identity fields in the transcript reader, matching the byUuid index and fork aggregation, so the reader never advertises a branch marker the fork path must reject. * fix: address round-8 review feedback for session branching (#8274) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix: address round-9 review feedback for session branching (#8274) * test(core): pin branch GC isolation from throwing warning callbacks (#8274) * fix(acp-bridge): reject rewind at admission while a prompt is active (#8274) * fix(web-shell): harden session branch publication Preserve direct ACP prompt preemption while fencing branch and rewind history mutations at the Session boundary. Convert branch publication, backup staging, cleanup, and stale-claim GC to asynchronous filesystem APIs, and surface unsupported hard-link commits as typed ACP and HTTP errors. Expand regression coverage and update the reviewed design contract. * fix(web-shell): harden historical response branching Reject branches during prompt admission and keep dispatched mutations owned until their real outcome is known. - remove detached timeouts across ACP, SDK, and WebUI - bound branch cleanup and make title scans asynchronous - avoid full branch-point scans during transcript pagination - add regression coverage from Core through the real daemon and browser * fix: address round-11 review feedback for session branching (#8274) * fix: address round-12 review feedback for session branching (#8274) * refactor(branching): remove branch-specific overdesign Simplify historical session branching around the minimum persistence, recording, and navigation invariants required by the Web Shell flow. - Replace branch claims and garbage collection with staged publication - Validate completed turns incrementally instead of reloading transcripts - Separate persisted branch creation from live session restoration - Bound SDK waits and prevent late results from replacing navigation - Remove unused checkpoint prompt IDs while reading legacy records Note: A pre-commit crash may leave hidden staging or orphan backups. * chore(sdk): update browser bundle budget Account for the combined historical branching and transcript projection APIs after merging main while keeping the browser bundle size guard narrowly bounded. * fix(branching): address review lifecycle gaps Harden historical session branching against cancellation, observer, navigation, and shutdown races found during review. - Normalize cancellation keys and bound close-time mutation waits - Preserve anchors after observer completion and load persisted forks - Report success only when the guarded session switch starts - Cover recorder cursors, fork cleanup, admission, and rollback - Align daemon events and branch errors with runtime behavior * refactor(session): simplify branching safeguards Reduce the session branching surface after review while preserving the critical concurrency, durability, and ownership guarantees. - Remove the unused full-chain resolver and test production entry points - Copy backups from verified open handles instead of using hard links - Reuse the bounded title scan instead of maintaining an async mirror - Deduplicate UI branch requests and fail fast for busy automatic turns - Consolidate repeated mutation tests and retain critical race coverage - Document the retained invariants and rejected overdesign explicitly * test(branching): simplify regression coverage Reduce duplicated branching tests while retaining regression coverage for the safety, concurrency, and lifecycle fixes introduced by this feature. - Consolidate symmetric bridge and agent scenarios with table-driven cases - Remove repeated cross-layer assertions and brittle implementation spies - Drop redundant UI permutations and branch-only visual snapshots * fix(serve): handle branch busy admission * fix(sdk): preserve v1 branch session contract Keep existing latest-state branch callers source- and wire-compatible while retaining the persisted-only behavior for historical checkpoint branches. - Restore no-anchor branches before returning their live client identity - Add a separate typed result for persisted historical branch requests - Clean up restored attachments on stale navigation and disconnect races - Cover immediate continuation and historical persistence independently * fix(daemon): guard branching history mutations Prevent branch creation and automatic Goal turns from racing session teardown or interactive history mutations. - Reject branch admission while a conditional close is authorized - Serialize Goal continuations behind the history mutation gate - Limit branch checkpoints to interactive prompts - Add regressions for close and Goal scheduling races * fix(branching): preserve fork and checkpoint semantics Keep branch checkpoints and file-history snapshots correct across resumed, forked, and non-interactive session flows. - Track the restored active-chain base before the first appended turn - Preserve backup file modes during fork publication - Exclude authenticated channel prompts from checkpoint recording - Add regressions for all three review failures * fix(branching): harden branch and rewind behavior Handle the remaining branch and rewind review findings without widening the feature contract. - Ignore benign concurrent branch rejections in the Web Shell - Validate rewind prompt IDs before using string operations - Pin mutation ordering, cleanup, compaction, and checkpoint invariants - Align sourced-fork fixtures with the canonical side_task value --------- Co-authored-by: heyang.why <heyang.why@alibaba-inc.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com> Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com> Co-authored-by: qwen-code-bot <qwen-code-bot@users.noreply.github.com> Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
37 KiB
Typed Daemon Event Schema v1
Overview
Every SSE frame emitted by the daemon on GET /session/:id/events has the shape { id, v, type, data, originatorClientId?, _meta? }. v: 1 is the current EVENT_SCHEMA_VERSION. type comes from the closed, version-pinned DAEMON_KNOWN_EVENT_TYPE_VALUES set in packages/sdk-typescript/src/daemon/events.ts. The envelope _meta field is stamped at the SSE write boundary by formatSseFrame() in packages/cli/src/serve/routes/sse-events.ts; see Envelope-level metadata.
The SDK exposes asKnownDaemonEvent(evt). It returns a discriminated KnownDaemonEvent for known event types and undefined for other types. SDK consumers can therefore handle forward compatibility without requiring a lockstep SDK upgrade when a newer daemon adds an event type; the session reducer records those as unrecognizedKnownEventCount.
The wire format lives in ../qwen-serve-protocol.md. This page is the payload contract for each event.
Responsibilities
- Provide the single source of truth for the event vocabulary (
DAEMON_KNOWN_EVENT_TYPE_VALUES). - Provide a typed envelope for each event type (
DaemonEventEnvelope<TType, TData>). - Provide pure reducers (
reduceDaemonSessionEvent,reduceDaemonAuthEvent) that project an event stream into SDK view state. - Broadcast the
typed_event_schemacapability tag as an informational signal. If the tag is absent,asKnownDaemonEventstill falls back tounknown.
Event vocabulary
Grouped by domain.
Core session
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
session_update |
S->C | Any ACP sessionUpdate notification: agent text, thought, tool call, or plan |
sessionUpdate: string, content?: ... (opaque ACP shape) |
session_metadata_updated |
S->C | PATCH /session/:id/metadata |
sessionId, displayName? |
session_died |
S->C terminal | channel.exited |
sessionId, reason, exitCode? | null, signalCode? | null |
session_closed |
S->C terminal | DELETE /session/:id or programmatic close |
sessionId, reason: 'client_close' | string, closedBy? |
session_snapshot |
S->C synthetic | Snapshot frame after SSE attach / replay | sessionId, currentModelId: string | null, currentApprovalMode: string | null, recordingDegraded: boolean |
session_recording_degraded |
S->C | The session transcript writer permanently stopped after an asynchronous write failure | sessionId, reason: 'write_failed' |
Subscriber-level synthetic frames
| Type | Trigger | Notes |
|---|---|---|
client_evicted |
Per-subscriber EventBus queue overflow. No id |
reason: 'queue_overflow' | 'queue_bytes_overflow' | string, droppedAfter?: number, queueSize?: number, maxQueued?: number, queuedBytes?: number, maxQueuedBytes?: number, eventBytes?: number; terminal only for the current subscriber, while the session remains alive. |
slow_client_warning |
Live frame backlog or live serialized-byte backlog >= 75%; force-pushed and has no id |
queueSize, maxQueued, lastEventId, queuedBytes?, maxQueuedBytes?, threshold?: 'frames' | 'bytes' | 'frames_and_bytes'; re-armed after both frame and byte measurements drop below 37.5%. |
stream_error |
SubscriberLimitExceededError or another route stream error |
error: string; terminal for the subscription. |
state_resync_required |
subscribe({lastEventId}) detects that the daemon ring no longer holds [lastEventId+1, earliestInRing-1], or the client cursor is from a previous bus epoch. Force-pushed before remaining replay frames and has no id. |
reason: 'ring_evicted' | 'epoch_reset' | string, lastDeliveredId: number, earliestAvailableId: number. This is a recovery signal, not terminal: the SSE stream stays open and replay + live frames continue. The SDK reducer sets awaitingResync = true and skips deltas until the caller resets with loadSession. |
history_truncated |
POST /session/:id/load returns a bounded replay snapshot after older in-memory replay entries were dropped. Prepended to compactedReplay and has no id. |
reason: 'replay_window_exceeded', truncatedEvents: number, retainedEvents: number, maxBytes: number, truncatedTurns?: number, fullTranscriptAvailable: boolean. This is a status marker, not a resync request; clients render it and continue applying retained replay. |
replay_complete |
Id-less sentinel emitted after the Last-Event-ID replay loop finishes, for both clean replay and ring-evicted paths, even when data.replayedCount === 0. No id |
replayedCount: number; lets consumers remove catch-up UI deterministically without a timeout. |
fullTranscriptAvailable is a boolean capability flag, not a literal true type. Current daemons emit true when /session/:id/transcript can be used to page the persisted transcript; older or constrained daemons may emit false, and clients should keep rendering the bounded replay normally.
Permissions (F3 + base)
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
permission_request |
S->C | Agent calls requestPermission |
requestId, sessionId, toolCall, options[]; the envelope stamps originatorClientId from the prompt originator. |
permission_resolved |
S->C | Mediator has decided | requestId, outcome (ACP PermissionOutcome) |
permission_already_resolved |
S->C | Vote arrives after the request was already decided | requestId, sessionId, outcome |
permission_partial_vote |
S->C | consensus policy records a non-final vote |
requestId, sessionId, votesReceived, votesNeeded (>= 1), quorum, optionTallies: Record<string, number>, originatorClientId? |
permission_forbidden |
S->C | Policy rejects a vote | requestId, sessionId, clientId?, reason: 'designated_mismatch' | 'remote_not_allowed', originatorClientId?; anonymous voters omit clientId. |
Models
| Type | Direction | Payload |
|---|---|---|
model_switched |
S->C | sessionId, modelId |
model_switch_failed |
S->C | sessionId, requestedModelId, error: string |
MCP guardrails (PR 14b + F2)
| Type | Direction | Payload |
|---|---|---|
mcp_budget_warning |
S->C | liveCount, reservedCount, budget, thresholdRatio: 0.75, mode: 'warn' | 'enforce', scope?: 'workspace' | 'session' |
mcp_child_refused_batch |
S->C | refusedServers: [{ name, transport, reason: 'budget_exhausted' }], budget, liveCount, reservedCount, mode: 'enforce', scope?: 'workspace' | 'session' |
mcp_server_restarted |
S->C | serverName, durationMs, entryIndex? for F2 multi-entry pool restarts |
mcp_server_restart_refused |
S->C | serverName, reason: 'budget_would_exceed' | 'in_flight' | 'disabled' | 'restart_failed', entryIndex?, details?. The fourth value, restart_failed, carries an underlying hard failure for pool-mode multi-entry restart. MCP_RESTART_REFUSED_REASONS rejects unknown reasons; an older SDK reducer silently drops additive new reason values because parseDaemonEvent returns undefined. Ship a new reason with an SDK that knows it. |
Mutation control (Wave 4 PR 16+17)
| Type | Direction | Payload |
|---|---|---|
memory_changed |
S->C | File memory: scope: 'workspace' | 'global', filePath, mode, bytesWritten; managed memory: scope: 'managed', source, taskId, touchedScopes |
agent_changed |
S->C | change: 'created' | 'updated' | 'deleted', name, level: 'project' | 'user' |
approval_mode_changed |
S->C | sessionId, previous, next, persisted: boolean |
tool_toggled |
S->C | toolName, enabled; affects the next ACP child spawn and does not mutate already-running sessions. |
settings_changed |
S->C | Workspace settings write completed. Payload is open; consumers should refresh with read-after-write. |
settings_reloaded |
S->C | Daemon workspace service reread settings. Payload is open. |
trust_change_requested |
S->C | workspaceCwd, desiredState: 'trusted' | 'untrusted', reason? |
workspace_initialized |
S->C | path, action: 'created' | 'overwrote' | 'noop', originatorClientId? |
github_setup_completed |
S->C | releaseTag, readmeUrl, secretsUrl?, workflows: [{path, status, sizeBytes?, error?}], gitignore: {path, status, added?, error?} |
memory_changed also covers sessionless managed-memory tasks. For those
payloads, scope is "managed", source is one of
"workspace_memory_remember", "workspace_memory_forget", or
"workspace_memory_dream", taskId is the queued task id, and
touchedScopes lists the managed memory scopes that changed ("user" and/or
"project"). No event is emitted when a remember/forget/dream task completes
without touching managed memory.
Auth device flow (PR 21)
These events are workspace-keyed, not session-keyed. The session reducer treats them as no-ops; reduceDaemonAuthEvent projects them into workspace-level state.
| Type | Direction | Payload |
|---|---|---|
auth_device_flow_started |
S->C | deviceFlowId, providerId, expiresAt |
auth_device_flow_throttled |
S->C | deviceFlowId, intervalMs |
auth_device_flow_authorized |
S->C | deviceFlowId, providerId, expiresAt?, accountAlias? |
auth_device_flow_failed |
S->C | deviceFlowId, errorKind, hint? |
auth_device_flow_cancelled |
S->C | deviceFlowId |
MCP runtime mutation
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
mcp_server_added |
S->C | Server added at runtime through POST /workspace/mcp/servers |
name, transport, replaced, shadowedSettings, toolCount, originatorClientId |
mcp_server_removed |
S->C | Server removed at runtime | name, wasShadowingSettings, originatorClientId |
Extensions lifecycle
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
extensions_changed |
S->C | Background extension install/refresh work completed or status change | refreshed, failed, status?: 'installed' | 'enabled' | 'disabled' | 'updated' | 'uninstalled' | 'failed', source?, name?, version?, error? |
Mid-turn message injection
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
mid_turn_message_injected |
S->C | Web-shell or remote client injected messages into a running turn via POST /session/:id/inject |
sessionId, messages: string[], originatorClientId?; consumers MUST compare originatorClientId to their own id before deduping. |
Turn lifecycle / assistant pushes
| Type | Direction | Trigger | Key payload fields |
|---|---|---|---|
prompt_cancelled |
S->C | Prompt was cancelled through explicit cancelSession route or originator SSE disconnect |
Envelope stamps originatorClientId for the canceling client. This means "cancellation requested", not "cancellation confirmed". Peer subscribers learn that the prompt has ended. |
turn_complete |
S->C | A turn completed successfully | sessionId, stopReason, promptId?, branchPoint?. promptId links to non-blocking prompt responses (202). Eligible completed turns include branchPoint: { assistantRecordUuid, checkpointUuid }. |
turn_error |
S->C | A turn failed | sessionId, message, code?, promptId?; same promptId correlation mechanism. |
session_rewound |
S->C | POST /session/:id/rewind succeeded |
sessionId, promptId, targetTurnIndex, filesChanged[], filesFailed[], originatorClientId? |
session_branched |
S->C | Legacy compatibility event; the current branch endpoint returns its result directly and does not publish this event | sourceSessionId, newSessionId, displayName, originatorClientId?. Readers retain support for older producers. |
followup_suggestion |
S->C | ACP child generated ghost-text follow-up suggestions after end_turn, forwarded over per-session SSE |
sessionId, suggestion, promptId; wire only carries suggestions whose getFilterReason()===null. Clients render them as input-placeholder ghost text and invalidate them on next sendPrompt. |
user_shell_command |
S->C | User started a shell command through POST /session/:id/shell; fanned out to other subscribers in the same session |
sessionId, command, shellId, originatorClientId?. There is no typed DaemonXxxData interface yet; asKnownDaemonEvent returns undefined and the UI normalizer parses it ad hoc. |
user_shell_result |
S->C | Result of the shell command above | sessionId, shellId, exitCode, output, aborted. Same ad hoc parsing note as user_shell_command. |
Architecture
| Concern | Source | Notes |
|---|---|---|
EVENT_SCHEMA_VERSION = 1 |
packages/acp-bridge/src/eventBus.ts |
Sent on every frame. |
DAEMON_KNOWN_EVENT_TYPE_VALUES |
packages/sdk-typescript/src/daemon/events.ts |
Closed list with 53 types. |
DaemonEventEnvelope<TType, TData> |
events.ts |
Generic envelope. |
DaemonKnownEventType |
events.ts |
typeof DAEMON_KNOWN_EVENT_TYPE_VALUES[number]. |
| Per-event payload types | events.ts |
Most event types have a DaemonXxxData interface; user_shell_* is currently parsed ad hoc by the UI normalizer. |
asKnownDaemonEvent(evt) |
events.ts |
Returns KnownDaemonEvent | undefined. |
reduceDaemonSessionEvent(state, evt) |
events.ts |
Projects into DaemonSessionViewState. |
reduceDaemonAuthEvent(state, evt) |
events.ts |
Projects into DaemonAuthState. |
isWorkspaceScopedBudgetEvent(evt) |
events.ts |
Detects F2 scope: 'workspace'. |
DaemonSessionViewState
reduceDaemonSessionEvent fills this view state. CLI TUI adapter, DaemonChannelBridge, and VS Code IDE consume it. Key fields:
alive: boolean- becomesfalseafter a terminal frame (session_died,session_closed,client_evicted,stream_error).currentModelId?: string- frommodel_switched.displayName?: string- fromsession_metadata_updated.recordingDegraded: boolean- sticky session recording state fromsession_recording_degraded; an explicitsession_snapshot.recordingDegradedvalue is authoritative.pendingPermissions: Record<string, DaemonPermissionRequestData>- open requests keyed byrequestId; cleared bypermission_resolved/permission_already_resolved.lastSessionUpdate?: DaemonSessionUpdateData- latestsession_update.lastModelSwitchFailure?: DaemonModelSwitchFailedData- frommodel_switch_failed.terminalEvent?- raw terminal event.streamError?: DaemonStreamErrorData- lateststream_errorpayload.unrecognizedKnownEventCount,lastUnrecognizedKnownEvent?- event was recognized byasKnownDaemonEventbut the reducer has no dedicated state for it yet.droppedPermissionRequestCount,lastDroppedPermissionRequestId?- malformed permission request could not enter the pending map.unmatchedPermissionResolutionCount,lastUnmatchedPermissionResolutionId?- permission resolution had no matching pending request.slowClientWarningCount,lastSlowClientWarning?- fromslow_client_warning.mcpBudgetWarningCount,lastMcpBudgetWarning?- frommcp_budget_warning.mcpChildRefusedBatchCount,lastMcpChildRefusedBatch?- frommcp_child_refused_batch.lastWorkspaceMutation?,lastWorkspaceMutationType?- frommemory_changed/agent_changed.approvalMode?,approvalModeChangedCount,lastApprovalModeChange?- fromapproval_mode_changed.toolToggleCount,lastToolToggle?- fromtool_toggled.workspaceInitCount,lastWorkspaceInit?- fromworkspace_initialized.mcpRestartCount,lastMcpRestart?- frommcp_server_restarted.mcpRestartRefusedCount,lastMcpRestartRefused?- frommcp_server_restart_refused.settings_changed/settings_reloaded- recognized byasKnownDaemonEvent; the session reducer does not maintain dedicated view-state fields, and UIs usually treat them as refresh signals.permissionVoteProgress: Record<string, DaemonPermissionPartialVoteData>- consensus voting progress.forbiddenVotes: DaemonPermissionForbiddenData[],forbiddenVoteCount- policy-rejected vote records, capped at 32.awaitingResync: boolean- set bystate_resync_required; cleared when consumer resets view state.resyncRequiredCount,lastResyncRequired?- resync observability.lastFollowupSuggestion?: DaemonFollowupSuggestionData- latest follow-up suggestion pushed by daemon.lastTurnComplete?: DaemonTurnCompleteData- latest successful turn completion.lastTurnError?: DaemonTurnErrorData- latest turn error.rewindCount,lastRewind?,lastBranch?- latest rewind / branch events.
DaemonAuthState
One entry per providerId, driven by auth_device_flow_*. Each flow exposes { deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError? }.
Flow
Producer side
flowchart LR
A["ACP child notification"] --> B["BridgeClient.sessionUpdate /<br/>BridgeClient.extNotification"]
B --> C{"Mapped to event type?"}
C -->|yes| D["EventBus.publish({type, data, originatorClientId?})"]
C -->|no| E["No emit (drop or log)"]
D --> F["Assign id + v=1, push to ring"]
F --> G["Fan out to all subscribers"]
Consumer side (SDK)
flowchart LR
A["SSE bytes"] --> B["parseSseStream -> DaemonEvent[]"]
B --> C["asKnownDaemonEvent(evt)"]
C -->|"KnownDaemonEvent"| D["reduceDaemonSessionEvent(state, evt)"]
C -->|"auth_device_flow_*"| E["reduceDaemonAuthEvent(state, evt)"]
C -->|"undefined"| F["unrecognizedKnownEventCount++<br/>(forward-compat)"]
Envelope-level metadata
Beyond each event's data payload, the daemon stamps two envelope-level fields.
_meta.serverTimestamp - daemon clock
EventBus.publish() in packages/acp-bridge/src/eventBus.ts stamps _meta.serverTimestamp when the event enters the bus. The BridgeEvent type includes _meta?: Record<string, unknown>, so internal daemon consumers do see _meta on every bus-published event. formatSseFrame() in packages/cli/src/serve/routes/sse-events.ts provides a fallback timestamp only for synthetic frames (e.g. stream_error) that bypass EventBus.publish.
{
"id": 47,
"v": 1,
"type": "session_update",
"data": { ... },
"_meta": { "serverTimestamp": 1716287345123 }
}
The merge preserves any existing _meta keys from the input event
({...input._meta, serverTimestamp: Date.now()}). Producers may attach
additional envelope-level _meta keys; EventBus.publish merges them with the
timestamp rather than overwriting.
Why it matters: multi-client UIs that render relative time or sort transcript blocks should use server time instead of each browser/tab/phone local clock. Server stamping keeps ordering consistent across clients.
SDK access: prefer event._meta?.serverTimestamp. Compatibility paths may also probe event.serverTimestamp or event.data._meta.serverTimestamp. Do not mix ACP payload data._meta with daemon envelope _meta.
originatorClientId
Events triggered by a request that carried a registered X-Qwen-Client-Id may stamp this field. See 08-session-lifecycle.md.
Tool-call _meta (provenance / serverId)
This is separate from envelope _meta: ACP session/update payloads can carry their own _meta in event.data._meta. ToolCallEmitter (packages/cli/src/acp-integration/session/emitters/tool-call-emitter.ts) stamps two fields on emitStart, emitResult, and emitError:
| Field | Type | Resolution rule |
|---|---|---|
provenance |
'builtin' | 'mcp' | 'subagent' |
ToolCallEmitter.resolveToolProvenance: subagentMeta wins with subagent; tool name matching mcp__<server>__<tool> maps to mcp; everything else maps to builtin. |
serverId |
string only when provenance === 'mcp' |
Extracted heuristically from mcp__<serverId>__<tool>. |
The existing _meta.toolName display name is preserved. UI uses these fields to render builtin / MCP server / subagent badges without reparsing the tool name.
SDK reducer behavior
reduceDaemonSessionEvent(state, evt) in packages/sdk-typescript/src/daemon/events.ts projects the stream into DaemonSessionViewState. The resync-related fields are:
awaitingResync: boolean- set bystate_resync_required; caller clears it, typically afterPOST /session/:id/loadresets view state.resyncRequiredCount: number- observability counter.lastResyncRequired?: DaemonStateResyncRequiredData- latest payload.
While awaitingResync = true, the reducer skips delta application and only allows the closed RESYNC_PASSTHROUGH_TYPES set:
| Passthrough type | Why it is still applied during resync |
|---|---|
state_resync_required |
Rare second resync should update lastResyncRequired / resyncRequiredCount. |
session_died |
Terminal stream signal must remain visible during resync. |
session_closed |
Same as above. |
client_evicted |
Same as above. |
stream_error |
Same as above. |
session_snapshot |
Full-state authoritative frame; safe to apply during resync. |
session_recording_degraded |
Sticky safety signal independent of transcript delta state. |
lastEventId still advances monotonically through advanceLastEventId(base) during resync. After the caller resets and clears awaitingResync, subsequent deltas align to the correct cursor.
reduceDaemonAuthEvent projects device-flow events into workspace-level auth
state entries shaped like
{deviceFlowId, status, providerId, expiresAt?, lastThrottleIntervalMs?, lastError?}
conceptually. In code the reducer stores status, errorKind, hint,
intervalMs, lastSeenEventId, authorizedExpiresAt, and accountAlias on
DaemonDeviceFlowReducerState; the daemon event payloads themselves remain the
per-event shapes listed above.
State and forward compatibility
- Add a known event type by appending to
DAEMON_KNOWN_EVENT_TYPE_VALUES. Old SDKs returnundefinedfor unrecognized event types through the fallback path and incrementunrecognizedKnownEventCount; new SDKs rely on the discriminated union. - Adding optional fields to an existing payload is safe because payloads are open (
{ [key: string]: unknown }). - Changing an existing payload shape is breaking and must bump
EVENT_SCHEMA_VERSIONplus advertise a compatible capability tag such ascaps.features.typed_event_schema_v2. idis per-session monotonic. Subscriber-level synthetic frames (client_evicted,slow_client_warning,stream_error,state_resync_required,replay_complete,session_snapshot) intentionally have no id so other subscribers do not see gaps.originatorClientIdlives on the envelope rather thandata. F3 partial-vote / forbidden payloads also merge it intodatathroughmergeOriginatorso view-state consumers do not need to retain the envelope.
Dependencies
10-event-bus.md- delivery channel.11-capabilities-versioning.md- how SDKs preflighttyped_event_schema,mcp_guardrail_events, andpermission_mediation.04-permission-mediation.md- how permission events are produced.13-sdk-daemon-client.md-asKnownDaemonEvent, reducers, and view-state shape.
Configuration
- Always advertised:
typed_event_schema,mcp_guardrail_events, andpermission_mediation(with supported policy modes). - No env var or flag directly controls the schema itself.
QWEN_SERVE_NO_MCP_POOL=1changes MCP eventscopefrom'workspace'to absent or'session'.
Caveats and known limits
- Six synthetic frame types intentionally have no
id; SDK code must not assume every event has an id. permission_partial_voteonly appears underconsensus.permission_forbiddenappears underdesignated,consensus, andlocal-only, but not underfirst-responder.mcp_child_refused_batchonly appears inmode: 'enforce';warnmode never refuses.auth_device_flow_*events are not session-keyed. When consuming throughDaemonSessionClient, usereduceDaemonAuthEventfor them rather than the session reducer.
References
packages/sdk-typescript/src/daemon/events.tspackages/acp-bridge/src/eventBus.ts(EVENT_SCHEMA_VERSION)packages/cli/src/serve/capabilities.ts(typed_event_schema,mcp_guardrail_events,permission_mediation)- Wire reference:
../qwen-serve-protocol.md