Skip to content

Commit 058b75d

Browse files
秦奇claude
andcommitted
feat(sdk/daemon-ui): reducer state machine — currentTool / approvalMode / cancellation propagation (PR-E)
Closes the "reducer state machine 设计缺漏" gap surfaced in the PR QwenLM#4328 review: - No `currentTool` — UI scans `blocks[]` to find the running tool - No mirrored approval mode — UI walks events to badge "plan"/"yolo" - Cancellation does not propagate — in-flight tool blocks stuck at 'in_progress' forever when the parent prompt is cancelled ## State additions (sidechannel, no transcript blocks) `DaemonTranscriptSidechannelState`: - `currentToolCallId?: string` — toolCallId of the in-flight tool - `approvalMode?: string` — mirrored from session.approval_mode.changed - `toolProgress: Record<string, { ratio?, step? }>` — per-tool progress shape (daemon-side emission of `tool.progress` events pending) ## Reducer behavior ### `tool.update` events `IN_FLIGHT_TOOL_STATUSES` = { pending, confirming, running, in_progress } `TERMINAL_TOOL_STATUSES` = { completed, success, failed, error, canceled, cancelled } - Tool enters in-flight: set `currentToolCallId = event.toolCallId` - Tool enters terminal: clear `currentToolCallId` if it matches - Unknown status (forward-compat): leave pointer untouched This avoids the failure mode where a future daemon-emitted status like `'paused'` would silently mark unknown states as either in-flight or terminal incorrectly. ### `session.approval_mode.changed` Mirror `event.next` onto `state.approvalMode`. Renderers can render a mode badge ("plan" / "default" / "auto-edit" / "yolo") with a single selector call, no event-stream walking. ### `assistant.done` with `reason === 'cancelled'` `propagateCancellationToInFlightTools` walks every tool block whose status is still in-flight and force-sets it to 'cancelled'. The daemon does not guarantee terminal `tool_call_update` for every in-flight tool when the parent prompt is cancelled, so this propagation prevents UI spinners from spinning forever. `currentToolCallId` is also cleared in the same call. Non-cancellation `assistant.done` (e.g., `reason: 'end_turn'`) does NOT propagate — in-flight tools remain in-flight until the daemon emits their terminal update naturally. ## Selectors - `selectCurrentTool(state)` — returns the running tool block, or undefined - `selectApprovalMode(state)` — returns the mirrored approval mode - `selectToolProgress(state, toolCallId)` — per-tool progress query All exported from `@qwen-code/sdk/daemon`. ## Scope deliberately deferred Subagent nesting (`parentBlockId` / `delegationId` / `DaemonSubagentTranscriptBlock`) is NOT in this PR. The shape needs design discussion (how to project nested events; whether to bake delegation tracking into transcript or sidechannel). PR-D / PR-F follow-up. ## Test coverage (51/51 pass) - currentToolCallId set on enter, cleared on terminal - approvalMode mirrors changes - Cancellation marks in-flight tools 'cancelled', leaves completed alone - Unknown status does NOT clear currentToolCallId (forward-compat) - Non-cancellation `assistant.done` does NOT propagate ## Roadmap PR-E of the unified follow-up to PR QwenLM#4328 (PR-A + PR-B + PR-E in this branch; PR-C / PR-D pending). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a0673b7 commit 058b75d

4 files changed

Lines changed: 400 additions & 2 deletions

File tree

packages/sdk-typescript/src/daemon/ui/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ export {
1212
formatBlockTimestamp,
1313
rebuildDaemonTranscriptBlockIndex,
1414
reduceDaemonTranscriptEvents,
15+
selectApprovalMode,
16+
selectCurrentTool,
1517
selectPendingPermissionBlocks,
18+
selectToolProgress,
1619
selectTranscriptBlocks,
1720
selectTranscriptBlocksOrderedByEventId,
1821
} from './transcript.js';

packages/sdk-typescript/src/daemon/ui/transcript.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,42 @@ export function createDaemonTranscriptState(
3333
toolBlockByCallId: {},
3434
trimmedToolNotificationByCallId: {},
3535
permissionBlockByRequestId: {},
36+
// PR-E sidechannel: track current tool / approval mode / progress
37+
toolProgress: {},
3638
nextOrdinal: 1,
3739
now: opts.now ?? Date.now(),
3840
maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS,
3941
};
4042
}
4143

44+
/**
45+
* Tool statuses that count as "in-flight" — when one of these is set, the
46+
* tool block is considered active and `state.currentToolCallId` mirrors
47+
* its id. Closed list; daemon-side may emit other status values (e.g.,
48+
* future `'paused'`) — those are NOT treated as in-flight here.
49+
*/
50+
const IN_FLIGHT_TOOL_STATUSES: ReadonlySet<string> = new Set([
51+
'pending',
52+
'confirming',
53+
'running',
54+
'in_progress',
55+
]);
56+
57+
/**
58+
* Tool statuses that terminate the in-flight phase. Any other status
59+
* (including unknown future ones) keeps the tool considered in-flight,
60+
* which is the forward-compat-friendly default — the alternative would
61+
* silently mark unknown states as terminal.
62+
*/
63+
const TERMINAL_TOOL_STATUSES: ReadonlySet<string> = new Set([
64+
'completed',
65+
'success',
66+
'failed',
67+
'error',
68+
'canceled',
69+
'cancelled',
70+
]);
71+
4272
export function appendLocalUserTranscriptMessage(
4373
state: DaemonTranscriptState,
4474
text: string,
@@ -97,6 +127,13 @@ function applyDaemonTranscriptEvent(
97127
break;
98128
case 'assistant.done':
99129
finishAssistant(next);
130+
// PR-E cancellation propagation: when the assistant turn was
131+
// cancelled, any in-flight tool block whose status the daemon
132+
// never updated to a terminal state would otherwise spin forever.
133+
// Force them to 'cancelled' so renderers can clear spinners.
134+
if (event.reason === 'cancelled') {
135+
propagateCancellationToInFlightTools(next);
136+
}
100137
break;
101138
case 'thought.text.delta':
102139
appendTextDelta(
@@ -140,8 +177,12 @@ function applyDaemonTranscriptEvent(
140177
// chat-stream transcript stays focused on user/assistant/tool/shell/
141178
// permission content. PRs in the C/D series may opt some of these
142179
// into transcript projection as structured non-chat blocks.
143-
case 'session.metadata.changed':
144180
case 'session.approval_mode.changed':
181+
// PR-E sidechannel: mirror the new approval mode onto state so
182+
// renderers don't have to walk events.
183+
next.approvalMode = event.next;
184+
break;
185+
case 'session.metadata.changed':
145186
case 'session.available_commands':
146187
case 'workspace.memory.changed':
147188
case 'workspace.agent.changed':
@@ -266,6 +307,7 @@ function upsertToolBlock(
266307
if (event.rawOutput !== undefined) existing.rawOutput = event.rawOutput;
267308
if (event.toolName) existing.toolName = event.toolName;
268309
if (event.toolKind) existing.toolKind = event.toolKind;
310+
updateCurrentToolPointer(state, event.toolCallId, event.status);
269311
return;
270312
}
271313

@@ -297,9 +339,54 @@ function upsertToolBlock(
297339
};
298340
appendBlock(state, block);
299341
state.toolBlockByCallId[event.toolCallId] = block.id;
342+
updateCurrentToolPointer(state, event.toolCallId, event.status);
300343
clearActiveText(state);
301344
}
302345

346+
/**
347+
* PR-E: maintain `state.currentToolCallId`. Sets when tool enters in-flight
348+
* status; clears when tool enters terminal status; leaves untouched for
349+
* unknown statuses (forward-compat).
350+
*/
351+
function updateCurrentToolPointer(
352+
state: DaemonTranscriptState,
353+
toolCallId: string,
354+
status: string | undefined,
355+
): void {
356+
if (status === undefined) return;
357+
if (IN_FLIGHT_TOOL_STATUSES.has(status)) {
358+
state.currentToolCallId = toolCallId;
359+
return;
360+
}
361+
if (TERMINAL_TOOL_STATUSES.has(status)) {
362+
if (state.currentToolCallId === toolCallId) {
363+
state.currentToolCallId = undefined;
364+
}
365+
return;
366+
}
367+
// Unknown status (forward-compat): leave pointer as-is.
368+
}
369+
370+
/**
371+
* PR-E cancellation propagation: walk every tool block whose status is
372+
* still in-flight and force it to `'cancelled'`. Triggered when
373+
* `assistant.done.reason === 'cancelled'` since the daemon does not
374+
* guarantee a terminal `tool_call_update` for every in-flight tool when
375+
* the parent prompt is cancelled.
376+
*/
377+
function propagateCancellationToInFlightTools(
378+
state: DaemonTranscriptState,
379+
): void {
380+
for (const blockId of Object.values(state.toolBlockByCallId)) {
381+
const block = getWritableBlockById(state, blockId);
382+
if (!block || block.kind !== 'tool') continue;
383+
if (!IN_FLIGHT_TOOL_STATUSES.has(block.status)) continue;
384+
block.status = 'cancelled';
385+
block.updatedAt = state.now;
386+
}
387+
state.currentToolCallId = undefined;
388+
}
389+
303390
function appendShellBlock(
304391
state: DaemonTranscriptState,
305392
event: Extract<DaemonUiEvent, { type: 'shell.output' }>,
@@ -648,6 +735,52 @@ export function selectTranscriptBlocksOrderedByEventId(
648735
return [...state.blocks].sort(compareBlocksByEventOrder);
649736
}
650737

738+
/* ──────────────────────────────────────────────────────────────────────────
739+
* PR-E selectors — sidechannel state queries
740+
* ──────────────────────────────────────────────────────────────────────── */
741+
742+
/**
743+
* Return the currently-running tool block, or `undefined` when no tool is
744+
* in flight. Used by UI to render a "正在运行 X" header without scanning
745+
* `blocks[]`.
746+
*/
747+
export function selectCurrentTool(
748+
state: DaemonTranscriptState,
749+
): Extract<DaemonTranscriptBlock, { kind: 'tool' }> | undefined {
750+
const id = state.currentToolCallId;
751+
if (!id) return undefined;
752+
const blockId = state.toolBlockByCallId[id];
753+
if (!blockId || blockId === TRIMMED_TOOL_BLOCK_ID) return undefined;
754+
const index = state.blockIndexById[blockId];
755+
if (index === undefined) return undefined;
756+
const block = state.blocks[index];
757+
return block?.kind === 'tool' ? block : undefined;
758+
}
759+
760+
/**
761+
* Approval mode currently active for the session, mirrored from
762+
* `session.approval_mode.changed` events. `undefined` until the daemon
763+
* emits at least one change event.
764+
*/
765+
export function selectApprovalMode(
766+
state: DaemonTranscriptState,
767+
): string | undefined {
768+
return state.approvalMode;
769+
}
770+
771+
/**
772+
* Per-tool progress query. Returns `undefined` if no progress has been
773+
* recorded for the given toolCallId. The shape `{ ratio?, step? }` matches
774+
* the eventual `tool.progress` event payload (daemon-side emission
775+
* pending — SDK is ready to consume).
776+
*/
777+
export function selectToolProgress(
778+
state: DaemonTranscriptState,
779+
toolCallId: string,
780+
): { ratio?: number; step?: string } | undefined {
781+
return state.toolProgress[toolCallId];
782+
}
783+
651784
function compareBlocksByEventOrder(
652785
a: DaemonTranscriptBlock,
653786
b: DaemonTranscriptBlock,

packages/sdk-typescript/src/daemon/ui/types.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,33 @@ export type DaemonTranscriptBlock =
492492
| DaemonPermissionTranscriptBlock
493493
| DaemonStatusTranscriptBlock;
494494

495-
export interface DaemonTranscriptState {
495+
/**
496+
* PR-E sidechannel state — workspace / session state mirror that tracks
497+
* non-chat events without polluting the chat-stream `blocks[]`.
498+
*/
499+
export interface DaemonTranscriptSidechannelState {
500+
/**
501+
* `toolCallId` of the tool currently in `running` / `in_progress` /
502+
* `pending`. Updated by the reducer when a `tool.update` event arrives;
503+
* cleared when the tool terminates. Used by UI to show a "正在运行 X tool"
504+
* status header without scanning `blocks[]`.
505+
*/
506+
currentToolCallId?: string;
507+
/**
508+
* Approval mode for the current session, mirrored from
509+
* `session.approval_mode.changed` events. Renderers use this to badge
510+
* the input area ("plan" / "default" / "auto-edit" / "yolo").
511+
*/
512+
approvalMode?: string;
513+
/**
514+
* Per-tool progress map, keyed by `toolCallId`. Populated by future
515+
* `tool.progress` events (daemon-side emission pending — the SDK is
516+
* ready to consume the field shape today).
517+
*/
518+
toolProgress: Record<string, { ratio?: number; step?: string }>;
519+
}
520+
521+
export interface DaemonTranscriptState extends DaemonTranscriptSidechannelState {
496522
blocks: DaemonTranscriptBlock[];
497523
lastEventId?: number;
498524
activeUserBlockId?: string;

0 commit comments

Comments
 (0)