Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,10 @@ describe('SubAgentTracker', () => {
type: 'text',
text: 'Hello, this is a response from the model.',
},
_meta: expect.objectContaining({
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/acp-integration/session/SubAgentTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ export class SubAgentTracker {
event.text,
'assistant',
event.thought ?? false,
undefined,
this.getSubagentMeta(),
);
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ export class MessageEmitter extends BaseEmitter {
async emitAgentThought(
text: string,
timestamp?: string | number,
subagentMeta?: SubagentMeta,
): Promise<void> {
const epochMs = BaseEmitter.toEpochMs(timestamp);
const _meta = this.buildChunkMeta(
BaseEmitter.toEpochMs(timestamp),
subagentMeta,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text },
...(epochMs != null && { _meta: { timestamp: epochMs } }),
...(_meta ? { _meta } : {}),
});
}

Expand All @@ -87,12 +91,16 @@ export class MessageEmitter extends BaseEmitter {
async emitAgentMessage(
text: string,
timestamp?: string | number,
subagentMeta?: SubagentMeta,
): Promise<void> {
const epochMs = BaseEmitter.toEpochMs(timestamp);
const _meta = this.buildChunkMeta(
BaseEmitter.toEpochMs(timestamp),
subagentMeta,
);
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
...(epochMs != null && { _meta: { timestamp: epochMs } }),
...(_meta ? { _meta } : {}),
});
}

Expand Down Expand Up @@ -139,12 +147,28 @@ export class MessageEmitter extends BaseEmitter {
role: 'user' | 'assistant',
isThought: boolean = false,
timestamp?: string | number,
subagentMeta?: SubagentMeta,
): Promise<void> {
if (role === 'user') {
return this.emitUserMessage(text, timestamp);
}
return isThought
? this.emitAgentThought(text, timestamp)
: this.emitAgentMessage(text, timestamp);
? this.emitAgentThought(text, timestamp, subagentMeta)
: this.emitAgentMessage(text, timestamp, subagentMeta);
}

private buildChunkMeta(
Comment thread
doudouOUC marked this conversation as resolved.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 两种不一致的序列化模式

buildChunkMeta(此处)显式选取字段(parentToolCallIdsubagentType),而 emitUsageMetadata(line 126-127)使用对象展开(...subagentMeta)。若 SubagentMeta 增加新字段,emitUsageMetadata 会静默包含,buildChunkMeta 会静默丢弃。建议统一为显式提取或统一使用展开。

— qwen3.7-max via Qwen Code /review

epochMs: number | undefined,
subagentMeta?: SubagentMeta,
): Record<string, unknown> | undefined {
const meta: Record<string, unknown> = {};
if (subagentMeta?.parentToolCallId) {
meta['parentToolCallId'] = subagentMeta.parentToolCallId;
}
if (subagentMeta?.subagentType) {
meta['subagentType'] = subagentMeta.subagentType;
}
if (epochMs != null) meta['timestamp'] = epochMs;
return Object.keys(meta).length > 0 ? meta : undefined;
}
}
29 changes: 27 additions & 2 deletions packages/sdk-typescript/src/daemon/ui/normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,11 +408,29 @@ function normalizeSessionUpdate(
}
case 'agent_message_chunk': {
const text = getTextContent(update['content']);
return text ? [{ ...base, type: 'assistant.text.delta', text }] : [];
if (!text) return [];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] 风格不一致:此处使用 early-return guard(if (!text) return []),而同 switch 中 user_message_chunk(line 407)和 shell_output/tool_output 使用三元表达式(return text ? [...] : [])。建议统一为同一种风格。

— qwen3.7-max via Qwen Code /review

const parentToolCallId = extractParentToolCallId(update);
return [
{
...base,
type: 'assistant.text.delta' as const,
text,
...(parentToolCallId ? { parentToolCallId } : {}),
},
];
}
case 'agent_thought_chunk': {
const text = getTextContent(update['content']);
return text ? [{ ...base, type: 'thought.text.delta', text }] : [];
if (!text) return [];
const parentToolCallId = extractParentToolCallId(update);
return [
{
...base,
type: 'thought.text.delta' as const,
text,
...(parentToolCallId ? { parentToolCallId } : {}),
},
];
}
case 'tool_call':
case 'tool_call_update':
Expand Down Expand Up @@ -465,6 +483,13 @@ function normalizeSessionUpdate(
}
}

function extractParentToolCallId(
update: Record<string, unknown>,
): string | undefined {
const meta = isRecord(update['_meta']) ? update['_meta'] : undefined;
return meta ? getString(meta, 'parentToolCallId') : undefined;
}

function normalizeToolUpdate(
update: Record<string, unknown>,
base: NormalizedEventBase,
Expand Down
6 changes: 6 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ function createState(
...(seed.permissionBlockByRequestId ?? {}),
},
toolProgress: { ...(seed.toolProgress ?? {}) },
activeAssistantBlockByParent: {
...(seed.activeAssistantBlockByParent ?? {}),
},
activeThoughtBlockByParent: {
...(seed.activeThoughtBlockByParent ?? {}),
},
lastResyncRequired:
seed.lastResyncRequired !== undefined
? { ...seed.lastResyncRequired }
Expand Down
114 changes: 104 additions & 10 deletions packages/sdk-typescript/src/daemon/ui/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
DaemonTranscriptReducerOptions,
DaemonTranscriptState,
DaemonUiEvent,
DaemonUiTextEvent,
DaemonUserShellTranscriptBlock,
} from './types.js';
import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js';
Expand Down Expand Up @@ -42,6 +43,8 @@ export function createDaemonTranscriptState(
toolBlockByCallId: {},
trimmedToolNotificationByCallId: {},
permissionBlockByRequestId: {},
activeAssistantBlockByParent: {},
activeThoughtBlockByParent: {},
// PR-E sidechannel: track current tool / approval mode / progress
toolProgress: {},
awaitingResync: false,
Expand Down Expand Up @@ -98,6 +101,8 @@ export function appendLocalUserTranscriptMessage(
next.activeUserBlockId = block.id;
next.activeAssistantBlockId = undefined;
next.activeThoughtBlockId = undefined;
next.activeAssistantBlockByParent = {};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] appendLocalUserTranscriptMessage 清理 keyed map 时未 finalize 流式块

直接 activeAssistantBlockByParent = {} 但未遍历引用的块设置 streaming = false。被孤立的 assistant 块保留 streaming: true,渲染层可能显示永久加载指示器。建议参考 finishAssistant 模式,清理前先 finalize:

for (const blockId of Object.values(next.activeAssistantBlockByParent)) {
  const block = getWritableBlockById(next, blockId);
  if (block?.kind === 'assistant') {
    block.streaming = false;
    block.updatedAt = next.now;
  }
}
next.activeAssistantBlockByParent = {};
next.activeThoughtBlockByParent = {};

— qwen3.7-max via Qwen Code /review

next.activeThoughtBlockByParent = {};
return trimTranscriptState(next);
}

Expand Down Expand Up @@ -364,6 +369,10 @@ export function selectPendingPermissionBlocks(
);
}

// Keyed (parentToolCallId) and scalar (activeAssistantBlockId) paths are
// fully independent. Neither clears nor finalizes the other's blocks.
// Only finishAssistant() or clearActiveText() with matching parentToolCallId
// can finalize keyed-path blocks.
function appendTextDelta(
state: DaemonTranscriptState,
kind: 'user' | 'assistant' | 'thought',
Expand All @@ -374,7 +383,24 @@ function appendTextDelta(
text: string,
event: DaemonUiEvent,
): void {
const existing = getWritableBlockById(state, state[activeKey]);
const parentId =
Comment thread
doudouOUC marked this conversation as resolved.
kind !== 'user' && 'parentToolCallId' in event
? (event as DaemonUiTextEvent).parentToolCallId
: undefined;
Comment thread
doudouOUC marked this conversation as resolved.

const parentMap =
parentId != null
? kind === 'assistant'
? state.activeAssistantBlockByParent
: kind === 'thought'
? state.activeThoughtBlockByParent
: undefined
: undefined;

const effectiveId =
parentMap && parentId != null ? parentMap[parentId] : state[activeKey];

const existing = getWritableBlockById(state, effectiveId);
if (existing && existing.kind === kind) {
existing.text = appendBoundedText(existing.text, text);
existing.updatedAt = state.now;
Expand All @@ -392,11 +418,37 @@ function appendTextDelta(
);
if (kind === 'assistant') block.streaming = true;
if (kind === 'thought') block.collapsed = true;
if (parentId != null) {
(block as DaemonTextTranscriptBlock).parentToolCallId = parentId;
}
appendBlock(state, block);
state[activeKey] = block.id;
if (kind !== 'user') state.activeUserBlockId = undefined;
if (kind !== 'assistant') state.activeAssistantBlockId = undefined;
if (kind !== 'thought') state.activeThoughtBlockId = undefined;

if (parentMap && parentId != null) {
parentMap[parentId] = block.id;
} else {
state[activeKey] = block.id;
}

if (parentId != null) {
if (kind === 'assistant') {
delete state.activeThoughtBlockByParent[parentId];
}
if (kind === 'thought') {
const evictedAssistId = state.activeAssistantBlockByParent[parentId];
if (evictedAssistId) {
const evicted = getWritableBlockById(state, evictedAssistId);
if (evicted?.kind === 'assistant') {
evicted.streaming = false;
evicted.updatedAt = state.now;
}
}
delete state.activeAssistantBlockByParent[parentId];
Comment thread
doudouOUC marked this conversation as resolved.
}
} else {
if (kind !== 'user') state.activeUserBlockId = undefined;
if (kind !== 'assistant') state.activeAssistantBlockId = undefined;
if (kind !== 'thought') state.activeThoughtBlockId = undefined;
}
}

function finishAssistant(state: DaemonTranscriptState): void {
Expand All @@ -406,6 +458,16 @@ function finishAssistant(state: DaemonTranscriptState): void {
existing.updatedAt = state.now;
}
state.activeAssistantBlockId = undefined;

for (const blockId of Object.values(state.activeAssistantBlockByParent)) {
const block = getWritableBlockById(state, blockId);
if (block?.kind === 'assistant') {
block.streaming = false;
block.updatedAt = state.now;
}
}
state.activeAssistantBlockByParent = {};
state.activeThoughtBlockByParent = {};
}

function upsertToolBlock(
Expand Down Expand Up @@ -542,7 +604,7 @@ function upsertToolBlock(
// never points at it. Effective-status keeps the pointer in sync
// with what was actually written to the block.
updateCurrentToolPointer(state, event.toolCallId, event.status ?? 'pending');
clearActiveText(state);
clearActiveText(state, event.parentToolCallId);
}

/**
Expand Down Expand Up @@ -842,6 +904,8 @@ function cloneTranscriptState(
blocks: state.blocks,
blockIndexById: state.blockIndexById,
toolBlockByCallId: { ...state.toolBlockByCallId },
activeAssistantBlockByParent: { ...state.activeAssistantBlockByParent },
activeThoughtBlockByParent: { ...state.activeThoughtBlockByParent },
trimmedToolNotificationByCallId: {
...state.trimmedToolNotificationByCallId,
},
Expand Down Expand Up @@ -926,6 +990,20 @@ function trimTranscriptState(
if (!keptIds.has(state.activeThoughtBlockId ?? '')) {
state.activeThoughtBlockId = undefined;
}
for (const [parentId, blockId] of Object.entries(
state.activeAssistantBlockByParent,
)) {
if (!keptIds.has(blockId)) {
delete state.activeAssistantBlockByParent[parentId];
}
}
for (const [parentId, blockId] of Object.entries(
state.activeThoughtBlockByParent,
)) {
if (!keptIds.has(blockId)) {
delete state.activeThoughtBlockByParent[parentId];
}
}
return state;
}

Expand Down Expand Up @@ -1021,10 +1099,26 @@ function allocateBlockId(state: DaemonTranscriptState, prefix: string): string {
return id;
}

function clearActiveText(state: DaemonTranscriptState): void {
finishAssistant(state);
state.activeUserBlockId = undefined;
state.activeThoughtBlockId = undefined;
function clearActiveText(
state: DaemonTranscriptState,
parentToolCallId?: string,
): void {
if (parentToolCallId) {
const assistId = state.activeAssistantBlockByParent[parentToolCallId];
if (assistId) {
const block = getWritableBlockById(state, assistId);
if (block?.kind === 'assistant') {
block.streaming = false;
block.updatedAt = state.now;
}
delete state.activeAssistantBlockByParent[parentToolCallId];
}
delete state.activeThoughtBlockByParent[parentToolCallId];
Comment thread
doudouOUC marked this conversation as resolved.
} else {
finishAssistant(state);
state.activeUserBlockId = undefined;
state.activeThoughtBlockId = undefined;
}
}

function appendBoundedText(existing: string, text: string): string {
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk-typescript/src/daemon/ui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface DaemonUiEventBase {
export interface DaemonUiTextEvent extends DaemonUiEventBase {
type: 'user.text.delta' | 'assistant.text.delta' | 'thought.text.delta';
text: string;
parentToolCallId?: string;
}

export interface DaemonUiUserShellCommandEvent extends DaemonUiEventBase {
Expand Down Expand Up @@ -647,6 +648,8 @@ export interface DaemonTextTranscriptBlock extends DaemonTranscriptBlockBase {
text: string;
streaming?: boolean;
collapsed?: boolean;
/** Used by the reducer for per-subAgent block routing; renderers may use it for nesting. */
parentToolCallId?: string;
Comment thread
doudouOUC marked this conversation as resolved.
}

export interface DaemonToolTranscriptBlock extends DaemonTranscriptBlockBase {
Expand Down Expand Up @@ -793,6 +796,8 @@ export interface DaemonTranscriptState
activeUserBlockId?: string;
activeAssistantBlockId?: string;
activeThoughtBlockId?: string;
activeAssistantBlockByParent: Record<string, string>;
activeThoughtBlockByParent: Record<string, string>;
blockIndexById: Record<string, number>;
toolBlockByCallId: Record<string, string>;
trimmedToolNotificationByCallId: Record<string, true>;
Expand Down
Loading