diff --git a/.changeset/fix-background-task-notification.md b/.changeset/fix-background-task-notification.md new file mode 100644 index 00000000000..157fbbf36b8 --- /dev/null +++ b/.changeset/fix-background-task-notification.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show background task completion notices in the session message stream. diff --git a/.changeset/fix-empty-thinking-parts.md b/.changeset/fix-empty-thinking-parts.md new file mode 100644 index 00000000000..6eca76a753b --- /dev/null +++ b/.changeset/fix-empty-thinking-parts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix empty thinking entries recorded for steps without reasoning content. diff --git a/.changeset/fix-history-paging-past-compaction.md b/.changeset/fix-history-paging-past-compaction.md new file mode 100644 index 00000000000..fc0f9d7f277 --- /dev/null +++ b/.changeset/fix-history-paging-past-compaction.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix earlier-message history paging returning empty pages before compaction points. diff --git a/.changeset/fix-plan-approval-display.md b/.changeset/fix-plan-approval-display.md new file mode 100644 index 00000000000..6c6571d897e --- /dev/null +++ b/.changeset/fix-plan-approval-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix plan approvals and plan blocks missing the plan content in the v2 message stream. diff --git a/.changeset/fix-steer-duplicate-user-message.md b/.changeset/fix-steer-duplicate-user-message.md new file mode 100644 index 00000000000..edffe16ce77 --- /dev/null +++ b/.changeset/fix-steer-duplicate-user-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix steered prompts appearing twice in the session message stream. diff --git a/.changeset/goal-status-transitions-only.md b/.changeset/goal-status-transitions-only.md new file mode 100644 index 00000000000..3fc87fa9ce1 --- /dev/null +++ b/.changeset/goal-status-transitions-only.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Emit goal status stream entries only when the goal status actually changes. diff --git a/.changeset/skill-activation-user-message.md b/.changeset/skill-activation-user-message.md new file mode 100644 index 00000000000..8699278ed6e --- /dev/null +++ b/.changeset/skill-activation-user-message.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show skill activations as user messages in the session message stream. diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index c4aef5eec49..a00481807a6 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -505,6 +505,7 @@ interface PlanRevisionPayload { key: string; sha256: string; bytes: number; + summary?: string; } /** diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 3c7ad890753..beade157c77 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -932,6 +932,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { response: AgentLLMRequestFinish, ): void { for (const part of response.message.content) { + if (part.type === 'think' && part.think === '' && (part as { encrypted?: string }).encrypted === undefined) continue; this.context.appendLoopEvent({ type: 'content.part', uuid: randomUUID(), @@ -950,6 +951,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { streamParts: StreamPartCollector, ): void { for (const part of streamParts.drainInterruptedContent()) { + if (part.type === 'think' && part.think === '' && (part as { encrypted?: string }).encrypted === undefined) continue; this.context.appendLoopEvent({ type: 'content.part', uuid: randomUUID(), @@ -1144,13 +1146,19 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { new AssistantDelta({ agentId: this.scopeContext.agentId, turnId, delta: part.text }), ); return; - case 'think': + case 'think': { + const hasPayload = + part.think !== '' || (part as { encrypted?: string }).encrypted !== undefined; + if (!hasPayload) return; onResponseEvent(); accumulate(part); - void this.dispatcher.dispatch( - new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId, delta: part.think }), - ); + if (part.think !== '') { + void this.dispatcher.dispatch( + new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId, delta: part.think }), + ); + } return; + } case 'image_url': case 'audio_url': case 'video_url': diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index 4eb7136d2a1..313458ed765 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -65,7 +65,7 @@ export interface PromptLaunchResult { export interface PromptReservation extends IDisposable { readonly id: string; - submit(message: ContextMessage): Promise; + submit(message: ContextMessage, opts?: { steer?: boolean }): Promise; } export const promptAdmission = Symbol('promptAdmission'); diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 81dcac6947e..7812ac873a6 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -9,7 +9,7 @@ import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { newMessageId } from '#/agent/contextMemory/messageId'; -import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/types'; +import { USER_PROMPT_ORIGIN, type ContextMessage, type PromptOrigin } from '#/agent/contextMemory/types'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; import { TurnSteer } from '#/agent/loop/turnOps'; @@ -157,6 +157,8 @@ export interface PromptSubmittedPayload { readonly status: 'running' | 'queued'; readonly content: ContentPart[]; readonly createdAt: string; + readonly steer?: boolean; + readonly origin?: PromptOrigin; } export class PromptSubmitted extends AgentEvent2 { @@ -179,6 +181,7 @@ export interface PromptStarted extends PromptStartedPayload {} interface Deferred { readonly promise: Promise; resolve(value: T): void; reject(reason: unknown): void } interface Record extends PromptSnapshot { state: PromptState; + steerIntended: boolean; readonly launchedDeferred: Deferred; readonly completionDeferred: Deferred; handle: PromptHandle; @@ -277,7 +280,7 @@ export class AgentPromptService implements IAgentPromptService { let submitted = false; return { id, - submit: async (message) => { + submit: async (message, opts) => { if (submitted) throw new Error2(ErrorCodes.REQUEST_INVALID, 'prompt reservation already submitted'); submitted = true; this.reservedPromptIds.delete(id); @@ -288,7 +291,7 @@ export class AgentPromptService implements IAgentPromptService { content: stripBundledSkillBlocks(message), }), ); - return this.enqueue({ id, message }); + return this.enqueue({ id, message }, opts); }, dispose: () => { this.reservedPromptIds.delete(id); @@ -296,7 +299,7 @@ export class AgentPromptService implements IAgentPromptService { }; } - async enqueue(input: PromptInput): Promise { + async enqueue(input: PromptInput, opts?: { steer?: boolean }): Promise { const id = input.id ?? input.message.id ?? newMessageId(); const message = { ...input.message, id }; const launchedDeferred = deferred(); @@ -304,7 +307,7 @@ export class AgentPromptService implements IAgentPromptService { const record = {} as Record; Object.assign(record, { id, userMessageId: id, createdAt: new Date().toISOString(), state: 'pending', message, - launchedDeferred, completionDeferred, + launchedDeferred, completionDeferred, steerIntended: opts?.steer === true, }); record.handle = { get id() { return record.id; }, get userMessageId() { return record.userMessageId; }, @@ -360,7 +363,7 @@ export class AgentPromptService implements IAgentPromptService { role: 'user', content: [...payload.input], toolCalls: [], - } }); + } }, { steer: true }); if (queued.state !== 'pending') { const turn = await queued.launched; return turn === undefined ? undefined : { turn_id: turn.id }; @@ -570,7 +573,7 @@ export class AgentPromptService implements IAgentPromptService { } private publishSubmitted(record: Record, status: 'running' | 'queued'): void { if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; - void this.dispatcher.dispatch(new PromptSubmitted({ agentId: this.scopeContext.agentId, promptId: record.id, userMessageId: record.userMessageId, status, content: stripBundledSkillBlocks(record.message), createdAt: record.createdAt })); + void this.dispatcher.dispatch(new PromptSubmitted({ agentId: this.scopeContext.agentId, promptId: record.id, userMessageId: record.userMessageId, status, content: stripBundledSkillBlocks(record.message), createdAt: record.createdAt, steer: record.steerIntended ? true : undefined, origin: record.message.origin })); } private publishStarted(record: Record): void { if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 631f417afc6..852d668df94 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -64,6 +64,8 @@ export interface AgentTaskNotificationContext { readonly severity: 'info' | 'warning'; readonly sourceKind: string; readonly sourceId: string; + readonly sourceAgentId?: string; + readonly raw?: string; } export interface AgentTaskWaitDelivery { diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index bdd89e1ca39..c4701b5bdb6 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -1301,6 +1301,8 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { severity: notification.severity, sourceKind: notification.source_kind, sourceId: notification.source_id, + sourceAgentId: typeof notification.agent_id === 'string' ? notification.agent_id : undefined, + raw: renderNotificationXml(notification), }), ); } diff --git a/packages/agent-core-v2/src/features/plan/planOps.ts b/packages/agent-core-v2/src/features/plan/planOps.ts index 317aa003ca4..08b8e36ac8a 100644 --- a/packages/agent-core-v2/src/features/plan/planOps.ts +++ b/packages/agent-core-v2/src/features/plan/planOps.ts @@ -62,6 +62,7 @@ export interface PlanRevisionRecordedEvent { readonly key: string; readonly sha256: string; readonly bytes: number; + readonly summary?: string; } const planRevisionSchema = z.object({ @@ -71,6 +72,7 @@ const planRevisionSchema = z.object({ key: z.string(), sha256: z.string(), bytes: z.number(), + summary: z.string().optional(), }); export class PlanRevision extends AgentEvent2 { diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 0cbae892a46..6e5ae4a22a5 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -208,6 +208,11 @@ export class AgentPlanService extends Service implements IAgentPlanService { const scope = this.agentCtx.scope(); const key = `plan/${id}/v${version}.md`; await this.blobs.put(scope, key, bytes); + const heading = content + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('#')); + const summary = heading === undefined ? undefined : heading.replace(/^#+\s*/, '').trim() || undefined; await this.dispatcher.dispatch( new PlanRevision({ agentId: this.agentCtx.agentId, @@ -216,6 +221,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { key, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.byteLength, + summary, }), ); } diff --git a/packages/agent-core-v2/src/features/todo/todoOps.ts b/packages/agent-core-v2/src/features/todo/todoOps.ts index db8f746e0ea..9ae80604ff8 100644 --- a/packages/agent-core-v2/src/features/todo/todoOps.ts +++ b/packages/agent-core-v2/src/features/todo/todoOps.ts @@ -16,6 +16,7 @@ const toolsUpdateStoreSchema = z.object({ export class ToolsUpdateStore extends AgentEvent2> { static override readonly type = 'tools.update_store'; static override readonly durable = true; + static override readonly observable = true; static override readonly schema = toolsUpdateStoreSchema; } export interface ToolsUpdateStore { diff --git a/packages/agent-core-v2/test/features/plan/plan.test.ts b/packages/agent-core-v2/test/features/plan/plan.test.ts index 9f4e674c571..288c2af07b8 100644 --- a/packages/agent-core-v2/test/features/plan/plan.test.ts +++ b/packages/agent-core-v2/test/features/plan/plan.test.ts @@ -301,6 +301,7 @@ describe('Plan service', () => { key: revisionKey('rev-plan', 1), sha256: createHash('sha256').update(content, 'utf8').digest('hex'), bytes: Buffer.byteLength(content), + summary: 'Plan', time: expect.any(Number), }, ]); diff --git a/packages/kap-server/docs/2026-09-04-v2-protocol-refactor-plan.md b/packages/kap-server/docs/2026-09-04-v2-protocol-refactor-plan.md new file mode 100644 index 00000000000..edbfbb08b6b --- /dev/null +++ b/packages/kap-server/docs/2026-09-04-v2-protocol-refactor-plan.md @@ -0,0 +1,218 @@ +# kap-server 统一 WS 消息协议重构计划 + +> 协议设计稿:code-app 仓 `docs/designs/2026-09-02-sdk-message-protocol.md`(1–15 章 + §16 修订记录)。 +> 验收基准:code-app 仓 `docs/designs/2026-09-02-sdk-message-protocol-examples.html`(24 个场景实例,全部消息是 wire 形状的逐字节正本)。 +> 客户端侧参照:code-app 仓 `docs/plans/2026-09-03-code-app-ws-v2-refactor-plan.md`(客户端重构计划,其 P0/P1 已完成: +> `packages/app-core/src/ws2/messages/` 的 zod schema 初版、mock server、恢复矩阵测试)。 + +## 1. 背景与目标 + +现行 wire 协议由四套并存的消息体系组成:51 型 agent 帧、19 型 `event.*` 帧、2 型 transcript 帧(`transcript.reset` / `transcript.ops`)、13+ 型控制帧(`subscribe` / `subscribe_v2` / cursors / journal 序号 / `resync_required` / volatile 标记)。同一事实最多有三个真相源(agent 帧、event 帧、transcript ops),同一改动要改多处,客户端还要为 transcript 包维护一套 op apply 层。 + +目标:按设计稿把 wire 收敛为一套**扁平自包含实体消息**——每个实体(turn / step / user / assistant / thinking / tool_call / system / interaction / task / todo / session.state / session / workspace / config 等)带 `state`/`status` 字段表达生命周期,内容恒为累积全量,delta 族可丢,恢复 = REST 历史 + in-flight step 回放 + 状态实体全量重发。kap-server 侧单一投影产出全部消息,zod schema 单一正本,旧四套整体退役。 + +非目标:操作类 REST(prompt 提交、审批应答、中断、配置写入等)的契约不变——操作的效果只经消息流下发,但操作入口本身不在本次重构范围;终端(terminal)通道维持死协议现状,不重接。 + +## 2. 现状盘点(勘察结论,含文件与行号) + +**帧生产链**:agent-core-v2 的 App 级 `IEventService` 与 Agent 级 `IEventBus` 双总线 → `SessionEventBroadcaster`(`src/transport/ws/sessionEventBroadcaster.ts`)内联投影 → 51 型 agent 帧(`src/protocol/events-zod.ts:1049-1108` 的 45 型 + 6 型透传:`context.spliced` / `plan.revision` / `prompt.queued` / `prompt.started` / `task.notified` / `turn.steer`);19 型 `event.*`(zod 内 13 型 + 动态构造的 5 型 interaction 帧(`sessionEventBroadcaster.ts:1217-1285`)+ `event.fs.changed`)。transcript 链:`TranscriptService` + `AgentTranscriptProjector`(`src/services/transcript/coreEventMap.ts`)→ `@moonshot-ai/transcript` 的 14 种 ops,per-agent op journal(容量 2000,纯内存)。 + +**两套持久化**(统一后只留一套): + +- `wire.jsonl`(`/sessions/{ws}/{sid}/agents/{aid}/wire.jsonl`):领域事件日志,冷重建的唯一事实源(`TranscriptService.readColdSnapshot`、`routes/messages.ts` 的 `/sessions/{id}/messages`)。**保留**,作为 REST 历史的来源。 +- WS 事件 journal(`/server/events/{sid}.jsonl` + `__global__.jsonl`):WS 帧信封日志,仅供 `getBufferedSince` 断线回放(`src/transport/ws/sessionEventJournal.ts`)。**整体退役**。 + +**恢复现状**:`GET /sessions/{id}/snapshot`(`as_of_seq` + 尾 100 条 + `in_flight_turn`)+ `getBufferedSince` 游标回放 + `transcript_since` ops 重放三条并存;in-flight 只覆盖 main agent 的文本累积与 running_tools(`InFlightTurnTracker`)。统一后由 §9 语义整体替换:无游标、无序号、无 replay 标记。 + +**schema 分布**:kap-server `src/protocol/`(`ws-control.ts` 控制帧全 zod、`events-zod.ts` 手写 58 型、各域 `rest-*.ts`);code-app `packages/app-core/src/api/kap-server/schema/` 手抄 18 文件 + `frameManifest.ts`(含 kap-server 缺 zod 的 11 型)。设计稿协议的 schema 初版已在 code-app `packages/app-core/src/ws2/messages/` 落地并被 mock server 与客户端消费——本计划的 P0 把它**迁回 kap-server 作为唯一正本**,消灭手抄。 + +**session.state 事实源全部现成**:`ISessionActivityView`(busy / main_turn_active / pending_interaction / last_turn_reason)、`IAgentProfileService` + `ISessionUsageService` + `ISessionTokenCountingService`(model / usage / context_tokens)、`IAgentActivityView`(phase 及其 turn/step/since)、`IAgentPermissionModeService`(permission)、`IAgentGoalService`(goal)、`IAgentPlanService` 与 swarm/tower features(modes)、`listSessionPendingInteractions`(pending 审批/提问)、`IAgentTaskService`(task)、`ISessionMetadata`(title)。 + +## 3. 目标架构 + +``` +agent-core-v2 Event2 总线(App + Agent 两级) + │ + ▼ +┌─────────────────────┐ ┌──────────────────────┐ +│ 规范化投影层 │ ──► │ WS v2 在线连接扇出 │ ──► 客户端 +│ (唯一投影) │ └──────────────────────┘ +│ 持有: │ +│ in-flight 累积 │ ◄── 回放合成(设计稿 §9.2) +│ 状态实体当前值 │ ◄── 全量重发(§9.2) +└─────────────────────┘ + │ 引擎自身持久化 + ▼ +wire.jsonl ──► 冷重建(REST /sessions/{id}/history,内部实现细节) +``` + +- **单一投影**:引擎事件只进一个投影层(`src/services/v2Projection/`),产出的只有新协议消息。没有第二份翻译、没有 suppression 表。引擎新事件类型若未接入:default 分支打 telemetry 并显式丢弃,绝不透传。 +- **投影层状态是直播的副产品**:为发出 `assistant` / `tool_call` 全量消息必须持有当前累积内容;回放只是把这些内容对新连接再发一遍(设计稿 §9.6,服务端对恢复无状态——没有 op journal、没有事件窗口、没有逐客户端游标)。 +- **schema 单源**:`src/protocol/v2/`(P0 从 code-app 迁入)为唯一正本。每条出站消息经 schema 校验(失败 = 服务端 bug,打 telemetry 并丢弃,绝不放行契约外消息);AsyncAPI 由同一份生成;code-app 经 workspace 直接 import(与现行 `@moonshot-ai/transcript` 的共享方式相同)。 +- **冷重建降级为内部实现**:transcript 包的 `groupTurns` / `foldFacts` 可继续用于 REST 历史,但它不再是协议概念——wire 上没有 ops,客户端不知道它的存在。 + +## 4. 阶段计划 + +### P0:协议 schema 单源 `src/protocol/v2/` + +把 code-app `packages/app-core/src/ws2/messages/`(15 个文件,zod object + type 双导出,`ServerMessage` 可辨识联合 25 型 + `ClientMessage` + `parseServerMessage` + `ContractViolation`)迁入 `packages/kap-server/src/protocol/v2/messages/`。逐字节不另起炉灶;kap-server comment-free 纪律下,迁入时剥掉全部注释。 + +- code-app 侧:`app-core/src/ws2/messages/` 改为 `export * from '@moonshot-ai/kap-server/protocol/v2'` 的 re-export(workspace 依赖,与 `@moonshot-ai/transcript` 同模式), kap-server 的 `package.json` exports 增加 `./protocol/v2` 子路径。迁移完成后删除 code-app 的实体定义,**全仓只此一份**。 +- `parseServerMessage` 的服务端用法:出站 `safeParse` 校验 + 失败 telemetry 丢弃(`outboundGuard`)。 +- 实例对拍:code-app 的 `pnpm validate:examples`(schema × 24 tab 实例 HTML,869 条消息)迁入本仓 CI 等价物,实例 HTML 仍由 code-app 持有时,用其 fixtures JSON(`scripts/mock-ws-server/fixtures/examples.json`)做输入;防漂移检查挂两仓各自 CI。 +- AsyncAPI:由 `clientControlOperations` / `serverSystemOperations` 的既有生成路径改为从 `protocol/v2` 生成(`src/protocol/asyncapi.ts`)。 + +**验收**:`pnpm validate:examples` 在两仓全绿;code-app 侧 `ws2/messages` 只剩 re-export;`pnpm lint`(含 no-comments 检查)通过。 + +### P1:规范化投影层 `src/services/v2Projection/` + +Event2 → `ServerMessage` 的唯一投影。按 Agent scope 的会话逐会话持有: + +- **InFlightAccumulator**:当前 in-flight turn/step 的累积态——assistant/thinking 累积文本、tool_call 的 `input_text` 累积 / `input` / `output` / 最新 `progress`、step 的 `retry` 最近值。输入为流式事件(`assistant.delta` / `thinking.delta` / `tool.call.delta` / `tool.progress`),输出为全量实体消息。 +- **StateEntities**:pending interactions、running tasks、最新 todo、排队中 user(`prompt.submitted` 尚未绑定 turn 的)、最新 session.state 快照——回放时逐一全量重发(§9.2)。 +- **SessionStateComposer**:session.state 唯一聚合点,从 §2 的事实源直读,任何相关域变更发全量快照;累计字段纪律(读最新,不跨消息求和)。 +- **事件映射**:见 §5 映射总表。用户消息与 turn 的绑定遵守「`user_message_id` 只盖在 turn 首批消息」规则;steer/cron/task 完成在 busy 时注入当前 turn 不开新 turn(设计稿 16.4)。 +- **todo 规则**:由 TodoWrite tool_call 驱动(`tools.update_store` → todo 消息全量覆盖);todo 不落盘,REST 由最后一个 TodoWrite done 的 input 还原(设计稿 16.2)。 + +**验收**:单测以事件序列驱动(fake Event2 总线),断言产出的消息序列与 code-app 24 个实例 fixture 逐 tab 一致(basic / tool / multi-tool / approval / question / todo / queue-abort / injection 等可由引擎事件直接合成的场景全对拍);schema 校验 0 违规。 + +### P2:REST 历史 `GET /api/v1/sessions/{id}/history` + +wire.jsonl 冷重建 → 按时间排序的实体消息载荷列表(与 WS 同型同 schema),游标 `before_turn` / `after_step` / `page_size`(`before_turn` 往旧翻、`after_step` 补新),响应同时回传 `in_flight?: { turn_id, step_id }`。落盘边界 = step 边界(§9.4):已完成 turn/step 及其内容进历史;user 创建即落盘;assistant/thinking 完成即落盘;tool_call 终态落盘;system 创建即完整落盘;interaction 终态落盘;todo 不单独落盘(由 TodoWrite done 的 input 还原)。 + +实现路径:复用 `TranscriptService.readColdSnapshot` 的 wire 读取与 undo/clear/steer 折叠(`src/services/transcript/wireRecords.ts` + `reduceContextTranscript`),出口改为协议实体投影(与 P1 共用实体构造代码,或经投影层对 wire 记录重放);分页用 keyset(turn/step id 即游标,语义明确,不再有 `hasMoreOlder` / `has_more_older` / `has_more` 三名并存)。 + +**验收**:对 fixture 的 REST section 逐 tab deep-equal(含 recovery tab 的 A/B 两个变体);`has_more` / `in_flight` 语义与实例一致;大会话(十万行 wire)冷重建在预算内完成(给出实测数)。 + +### P3:WS v2 传输 `transport/ws/v2/` + +新端点 `/api/v2/ws`(v1 原样保留到 P6)。鉴权复用 `Sec-WebSocket-Protocol` bearer(`transport/ws/bearerProtocol.ts`)。 + +- **握手**:连接即 `hello { protocol_version: 2, server_id, capabilities: ['step_replay_v1','interaction_v1','subagent_channel_v1'] }`;`subscribe { id, session_id, agent_id?, omit? }` → `ack { id, code }`(code 复用 REST 的 ErrorCode 枚举,消灭现行 ack 魔法数字 1);`unsubscribe` 对称;协议级 `error` 帧;心跳用 WS 协议层 ping/pong(库自带,删除应用层 nonce 帧)。 +- **恢复载荷合成**(§9.2,与 P1 状态天然同源):in-flight turn 封面 → step → 该 step 内容实体(assistant/thinking 取当前 `streaming` 全量、tool_call 取当前状态)→ 状态实体(pending interaction、running task、最新 todo、尚无 turn 的 running user、最新 session.state)。无 replay 标记,恢复与直播同一序列(§8 会话序列化:恢复排进同一会话序列,通常无缝隙无重叠;客户端幂等覆盖兜底)。 +- **omit**:按消息 type 精确屏蔽下行(订阅参数而非协议分档)。 +- **背压**:每连接有界出站队列,溢出即以 `error { code: 'backpressure_overflow' }` 断开该连接——慢客户端的代价是一次 §9 恢复,系统内存安全不受影响。删除现行的 bufferedAmount 延迟强发与 delta 合批逻辑(delta 可丢由类型决定,不再有合批)。 +- **子代理通道**:`subscribe` 带 `agent_id` 时按同一条恢复+直播路径下发该 agent 的消息流;主通道不含子代理消息(`agent_id` 即过滤,不再有 `agent_filter` / side-channel 重发)。 +- **全局消息**(workspace / config / 通知型 / session 索引变更)按现 `addGlobalTarget` 等价物扇出到全连接。 + +**验收**:对 code-app `scripts/mock-ws-server/verify.mjs` 的等价客户端测试全过(hello/ack/omit/backpressure/子通道/恢复载荷逐字节对拍 fixture oracle);断线重连无重复实体(幂等);`--debug-endpoints` 下可观测每连接队列深度。 + +### P4:全消息面覆盖 + +按域收口,逐域对照实例 fixture(24 tab 全量): + +- session.state 全字段(goal / modes / last_turn_reason / pending_interaction / usage by_model+current_turn+total / context_tokens);`session` 索引消息(created / updated(自动标题)/ archived / deleted)。 +- workspace 三 subtype;config 全量 + `changed_fields`;`config.warning`;通知型三类(model_catalog / plugin / capability,客户端 REST 重拉,除这三类外不允许通知型消息)。 +- system 各 subtype:compaction(before/after tokens + summarized_through_turn)、undo(undo_turn_id)、clear、goal、plan.enter / plan.exit / plan.revision、swarm.enter / swarm.exit、skill、notice、hook、interruption(reason + turn_id)。 +- cron:`user { origin: { kind: 'cron', cron_id, schedule } }`;忙时 = 系统发起的 steer(`steered_at`)。 +- sideChat:`turn { origin: { kind: 'side' } }`,`main_turn_active: false` 不打断主对话。 +- attachment:turn/user 的 `attachment_ids`。 + +**验收**:24 tab 全部端到端对拍(真实引擎驱动 kap-server,code-app 客户端对 mock 同一套 fixtures 已验证的渲染路径直接复跑);子通道展开/收起流量断言(主通道无子代理消息泄漏)。 + +### P5:双跑灰度与对拍 + +- v2 与 v1 并存期:同一连接可同时持有 v1 订阅(旧客户端)与 v2 订阅(新客户端);服务端两套投影并行,资源开销实测登记。 +- 对拍:code-app 的 24 个实例 fixture 由真实 kap-server 回放生成一次(录制脚本),与手写 fixture deep-equal——防止实例与实现互相迁就。 +- 冒烟:code-app desktop(内嵌 server)+ web(daemon)双端:会话创建 → 发送 → 工具 → 审批 → 刷新 → 中断 → 恢复。 + +**验收**:录制回放对拍全绿;双端冒烟无阻塞性缺陷;v1 客户端行为零回归(kap-server 自身 v1 测试套件全绿)。 + +### P6:旧协议死亡清单 + +逐项勾销(每项删除后 `pnpm lint && pnpm test` 全绿才勾): + +| 旧物 | 去向 | +| --- | --- | +| 51 型 agent 帧(events-zod 45 型 + 6 透传) | 塌缩进实体消息(生命周期进 `state` 字段) | +| 19 型 `event.*` 帧 | 会话级进 `session.state` / `session`;全局进 `workspace` / `config` / 通知型 | +| `transcript.reset` / `transcript.ops`、`subscribe_v2` 与 transcript grades | 删除;恢复走 §9 | +| `subscribe` / `unsubscribe`(v1)、`client_hello` 的 cursors / agent_filter | 统一为 v2 `subscribe(session_id, agent_id?, omit?)` | +| 会话 journal + seq/epoch、transcript op journal + per-agent seq、`getBufferedSince`、`resync_required` | 全部删除;恢复不依赖任何序号 | +| `volatile` 标志 | 删除;delta 族天然即可丢,由类型决定 | +| WS 事件 journal(`/server/events/*.jsonl` + `__global__`) | 删除(文件与读写代码) | +| 应用层 ping/pong(nonce 帧) | 删除;用 WS 协议层心跳 | +| 终端帧 `terminal_*`、`abort`(WS 侧) | 现状即死协议,删除(terminal 未来若做,见开放问题) | +| `InFlightTurnTracker` / `SubagentRosterTracker` / snapshot 的 `in_flight_turn` 组装 | 由投影层 in-flight 累积取代 | +| code-app 手抄 18 个 schema 文件 + `frameManifest.ts` | 删除;import 同一份 schema(code-app 侧同步勾销) | +| `TRANSCRIPT_PROJECTED_EVENT_TYPES` / `suppressedByTranscript` | 删除;无第二通道即无抑制 | +| delta 合批(`coalesceFrames`)与 bufferedAmount 延迟强发 | 删除;delta 可丢 + 有界队列背压取代 | + +## 5. 事件 → 消息映射总表 + +| agent-core-v2 事件(§2 来源) | 产出消息 | +| --- | --- | +| `turn.prompt`(durable) | `user { status: 'running', origin? }`(cron/hook 等由 `PromptOrigin` 映射) | +| `prompt.submitted / queued / started / completed` | `user` 状态翻转(`finished_at`;排队 = running 且尚无 turn 可推导) | +| `turn.started` | `turn { state: 'running', origin, user_message_id?(首批盖章) }` | +| `turn.steer` | `user { steered_at }`(注入当前 turn) | +| `turn.cancel / turn.ended` | `turn { state: 'completed' }`;取消由 `system(interruption)` 表达;失败在 step | +| `turn.step.started / completed / interrupted / retrying` | `step` 全量(usage / finish_reason / retry / end_reason / end_message) | +| `assistant.delta / thinking.delta` | 累积后 `assistant/thinking { status: 'streaming', text: 全量 }` + 可丢 `*.delta` | +| `tool.call.started / tool.call.delta / tool.progress / tool.result` | `tool_call` 全量(state / input / input_text / progress / output / error)+ 可丢 `tool_call.delta` / `tool.progress` | +| `interaction.request / interaction.resolved` | `interaction { state: pending → 终态, request, response }` | +| `task.started / terminated / notified` | `task` 全量(kind / state / detached / output_tail / result_summary) | +| `subagent.spawned / started / suspended / completed / failed` | `task { kind: 'subagent', child_agent_id }` + 子通道消息流 | +| `tools.update_store`(todo) | `todo` 全量覆盖 | +| `compaction.started / completed / blocked / cancelled` | `system(compaction)` + session.state 的 context_tokens 刷新 | +| `context.undo / context.undone / context.clear` | `system(undo { undo_turn_id })` / `system(clear)` | +| `goal.updated / goal.clear` | session.state.goal + `system(goal)`(状态变更) | +| `plan_mode.enter / cancel / exit / plan.revision` | `system(plan.*)` + session.state.modes.plan | +| `cron.fired` | `user { origin: { kind: 'cron', cron_id, schedule } }`(忙时 = steer,`steered_at`) | +| `agent.activity.updated / agent.status.updated` 聚合域 | `session.state` 全量快照(phase / model / usage / permission / modes / goal) | +| `session.meta.updated` / `event.session.*` | `session { subtype: created/updated/archived/deleted }` | +| `event.workspace.*` | `workspace { subtype }` | +| `event.config.changed / event.config.warning` | `config { changed_fields }` / `config.warning` | +| `event.model_catalog.changed / event.plugin.changed / event.capability.changed` | 通知型 `model_catalog` / `plugin` / `capability` | +| `hook.result / skill.activated / plugin_command.activated` | `system(hook)` / `system(skill)` / `system(notice)`(按语义归位) | +| `shell.started / output / completed` | `tool_call`(Bash 系输出并入 output_tail/progress) | +| `event.fs.changed` | 不进 v2 主协议(fs watch 是独立订阅面,P6 前维持现状或随 `watch_fs_*` 一并退役——见开放问题) | + +## 6. 关键设计决策(实现时不得漂移) + +- **实体 id 规则**:`turn_id = t{N}`(会话内单调);`step_id = {turn_id}.{ordinal}`;`message_id = {step_id}.u{N} / .a{N} / .h{N}`(user 可 turn 级 `{turn_id}.u0`);`tool_call_id` / `interaction_id` / `task_id` / `todo_id` / `system_id` 由投影层统一分配。客户端 replace-by-id 依赖这些 id 的稳定唯一性(含 agent 命名空间隔离:实体身份 = `agent_id` + type + id)。 +- **累积全量生产**:投影层持有 in-flight 累积;任何实体状态变更发**全量**实体(不是 patch);delta 族仅作逐字渲染优化并行发送(可丢:客户端没占位就丢,下一条全量自愈)。 +- **user 两态与绑定**:`status: 'running' | 'completed'` 只表达「有没有被消费」;排队 = running 且尚无 turn 可推导;`created_at` / `finished_at` / `steered_at` 三时间戳;`turn.user_message_id` 只盖在 turn 产出的首批消息上。 +- **turn 两态**:`state: 'running' | 'completed'`,无 error 字段;取消由 `system(interruption)` 表达,失败在 step(`step.state: 'failed'` + `end_reason` / `end_message`)或 tool_call(`state: 'error'`)。 +- **落盘边界 = step 边界**:REST 含已完成 step(及其内容),回放含未完成 step;step 进行中的部分落盘(assistant 文本、完成的 tool 结果)允许 REST 与回放轻微重叠,幂等覆盖,不处理。 +- **session.state 聚合**:服务端唯一聚合点,客户端不做任何跨消息推理(这是消灭「三源合并」的落点);每次变更发全量;`usage` / `context_tokens` 为截至当前的累计值或当前值,读最新一条,不跨消息求和。 +- **回放无状态**:恢复数据 = wire.jsonl(REST)+ 投影层直播副产品(in-flight 累积、状态实体当前值);没有第三种存储。服务端重启 = 所有客户端重连 = 走一遍 §9.3,天然正确。 +- **出站纪律**:每条消息过 schema;失败打 telemetry 丢弃;绝不放行契约外消息,绝不透传未接入投影的引擎事件。 +- **写路径不动**:操作类 REST 契约不变;操作效果只经消息流下发(「触发事件 event.xxx」的说法整体消亡——客户端等的是对应的实体消息)。 + +## 7. 工程约束(本仓纪律) + +- `packages/kap-server`(与 agent-core-v2、transcript)是 comment-free zone:迁入与新增代码零注释(lint 豁免指令除外),由 `scripts/check-no-comments.mjs` 强制。 +- REST 走 `middleware/defineRoute` 声明式注册(zod 请求/响应 + ErrorCode 映射),不走手写 reply。 +- 测试向既有测试文件聚拢(每域一个测试文件),事件驱动用 fake Event2 总线而非真实引擎拉起全 DI。 +- 变更落地按 `gen-changesets` skill 生成 changeset(默认 `minor`,major 需用户确认)。 +- 全程不做旧服务端兼容逻辑:v1 在 P6 前共存于独立端点,新协议不按旧客户端降级。 + +## 8. 风险与开放点 + +- **schema 包归属**:P0 放 `src/protocol/v2/`(kap-server 内);若后续 klient / 其他消费方增多,可上移独立协议包——但无论在哪,严禁第二份手抄。 +- **fs watch**:`event.fs.changed` + `watch_fs_*` 是独立订阅面,是否并入 v2(workspace 域或独立通道)未定,P6 前维持现状。 +- **终端**:`terminal_*` 帧已死;未来若做终端,按开放问题单独设计(不复用旧帧)。 +- **大会话冷重建成本**:wire.jsonl 全量重放的 REST 成本需要 P2 实测;超预算时引入分页投影缓存(内部实现,不进协议)。 +- **多 tab 并存**:同会话多连接各自收到同一份恢复载荷(§9 无差异),P3 测试覆盖。 + +## 9. P1 落地备忘(实现口径,后续阶段不得漂移) + +- **事件源二分**:observable 事件经 Agent 级 `IEventBus` 直达投影层;durable-only 域不走总线——审批走 observable 的 `permission.approval.requested/resolved`(与工具事件同通道、时序确定),question 走 sessionInteractions 枢纽(投影器暴露 `applyInteractionPending/Resolved` 直接方法),todo 走 `tools.update_store`(live 绑定时可用 `IAgentTodoService.onDidChange` 等价驱动),interaction.request/resolved、plan_mode.*、goal.*、context.* 等 durable 记录是 P2 冷重建的来源。 +- **steer 标记**:引擎 `prompt.submitted` 新增可选 `steer: true`(submitSteer 路径,agent-core-v2 `promptService`)。投影层对 steer 提交保持悬挂(不发排队帧),`prompt.steered` 到达时直接把 user 落进当前 turn(`{turn}.u{N}` + `steered_at`);排队提交照旧立即上时间线(预测 turn id `t{maxTurn+queueLen+1}`)。 +- **id 分配**:turn `t{N}`(引擎 turnId+1)、step `{turn}.{step}`、文本 `{step}.a{N}`/`.h{N}`(每 step 各起)、user `{turn}.u{N}`(每 turn 起)、system/todo 投影器分配 `m_{NN}`/`td_{NN}`(类型前缀+两位序号,会话内单调,REST 冷重建按同规则重放保证一致);tool_call/interaction/task 用引擎 id 透传。`approval_id` 在 interaction pending 后回链 tool_call 重发帧(同时间戳),并保留到终态帧。 +- **usage 口径**:turn 完成帧 `input_tokens = Σ step.input_other`、`output_tokens = Σ step.output`,不发 `cached_tokens`;step 帧 usage 四字段(input_other/output/input_cache_read/input_cache_creation);session.state.usage 读最新快照不求和。 +- **流式文本**:占位帧(`status: streaming, text: ''`)与首个 delta 同帧时间;终态全量帧与下一事件同帧时间;空 delta 只开占位不发 delta 帧(`announced` 标记);retry 重置在流文本但 message_id 不变(textSeq 不回退);kind 切换/工具事件/step 收官都会关闭在流文本。 +- **时间戳纪律**:同一引擎事件产出的全部消息共享事件时间;`started_at` = 实体首帧时间、`ended_at`/`finished_at` = 终态帧时间;实例 fixture 已按此归一化(code-app `scripts/mock-ws-server/normalize-examples.mjs`,含 phase.since 真实 epoch 修正),HTML 为唯一来源、examples.json 为抽取产物、kap-server test/fixtures/v2-examples.json 为 vendor 副本。 +- **tool.result**:error 与 output 互斥(error 帧不带 output);done 帧清掉残留的 progress。 +- **已知引擎落差**:`turn.step.interrupted` 引擎 payload 无 usage——已按引擎真相结案(fixture 移除该字段,投影层仅在有值时映射)。 + +## 10. P2–P4 落地备忘 + +- **REST 排序**:turn 组(turn 序)→ 组内 turn 封面(仅已结束 turn)→ step 组(step 序:step 封面〔仅已完成〕→ 组内容 wire 序);turn 级 user(含 steer)挂产生时的 step 组、紧跟封面后。在飞 turn 无封面但其已完成 step 组照常进历史、user 保持 running,`in_flight { turn_id, step_id }` 取最近 step.begin。keyset:`before_turn` 往旧、`after_step` 补尾、默认最新 `page_size` 个 turn 组。 +- **冷折叠**:undo 不删实体(只出 system(undo) marker);clear/compaction 为 floor(floor 前折叠出默认页、has_more 标记、compaction marker 置顶,`summarized_through_turn` 由当前 turn 序推导);llm-retry 的失败尝试折叠(同 ordinal step.begin 复兴、started_at 保留首次、正文只留重试后);REST 不含 todo 实体(恢复载荷的状态实体)。封面帧 `timestamp = 终态记录 time + 5ms`、user `finished_at = prompt.completed/aborted`、文本 ts = 最后 content.part、tool_call ts = result、interaction ts = resolved。10 万行 wire 冷重建 ~240ms。 +- **恢复载荷**(`AgentV2Projector.recoveryEntities`):在飞 turn 封面 → 当前 step → 在流实体全量(累积 streaming)→ pending interactions → running tasks → 最新 todo → **仅 queued 态 running user**(在飞 turn 的开场 user 不进恢复)→ 重 compose 的 session.state。恢复与直播同一序列、无 replay 标记;`endedAt` 与帧 ts 解耦(断线期间收官、补发时刻重发)。`SessionStateComposer.hasFacts()` 决定空恢复不发 state。 +- **传输**:`/api/v2/ws` 与 v1 并存;hello 即恢复(无游标协商);ack.code 用 ErrorCode 枚举;hello capabilities 以 fixture 为准(`step_replay_v1` + `interaction_v1`);有界出站队列(容量 256 / in-flight 64)溢出即 `backpressure_overflow` 断开;心跳用 ws 协议层 ping/pong;每帧过 schema(outboundGuard)。binder facts 合批(同微任务多 patch 只 compose 一次)且不冲刷在流文本(`applyFacts(patch, time, flushTexts=false)`)。 +- **全局扇出**:App 级 `IEventService` → session 索引帧(meta.updated 全量 SessionInfo + changed_fields、created/archived;无 deleted 事件)、workspace 三 subtype(复用 toWireWorkspace)、config 脱敏全量 + changed_fields(脱敏在发布源头 toConfigResponse)、config.warning、model_catalog/plugin/capability 薄通知(客户端 REST 重拉)。未订阅连接也收全局帧。turn_count 取 live 投影器 maxTurnId+1。 +- **system subtype 全谱**:compaction / undo / clear / goal / plan.enter / plan.exit / plan.revision / swarm.enter / swarm.exit / skill / notice / hook / interruption,id 统一 `m_{NN}`。 +- **引擎侧已补**:`prompt.submitted.steer`(submitSteer 标记)、`plan.revision.summary`(首个 markdown 标题);`cron.fired` 无 promptId——投影层内部合成绑定(按 origin 关联),不改引擎。 diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index 92f170fd63f..5982c2eaefa 100644 --- a/packages/kap-server/package.json +++ b/packages/kap-server/package.json @@ -13,6 +13,10 @@ "types": "./src/contract.ts", "default": "./src/contract.ts" }, + "./protocol/v2": { + "types": "./src/protocol/v2/index.ts", + "default": "./src/protocol/v2/index.ts" + }, "./search-worker-runtime": { "types": "./src/search/worker/runtime.ts", "default": "./src/search/worker/runtime.ts" diff --git a/packages/kap-server/src/protocol/rest-prompt.ts b/packages/kap-server/src/protocol/rest-prompt.ts index 96207416c60..0fd27ab3374 100644 --- a/packages/kap-server/src/protocol/rest-prompt.ts +++ b/packages/kap-server/src/protocol/rest-prompt.ts @@ -29,6 +29,7 @@ export const promptSubmissionSchema = z.object({ goal_objective: z.string().optional(), goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), disabled_tools: z.array(z.string()).optional(), + steer: z.boolean().optional(), prompt_id: z.string().min(1).optional(), skills: z.array(promptSkillActivationSchema).min(1).optional(), }); diff --git a/packages/kap-server/src/protocol/v2/index.ts b/packages/kap-server/src/protocol/v2/index.ts new file mode 100644 index 00000000000..e90347d7e91 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/index.ts @@ -0,0 +1 @@ +export * from './messages/index'; diff --git a/packages/kap-server/src/protocol/v2/messages/assistant.ts b/packages/kap-server/src/protocol/v2/messages/assistant.ts new file mode 100644 index 00000000000..cbade3f8da9 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/assistant.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; + +const streamingTextFields = { + ...timelineBaseFields, + message_id: z.string(), + turn_id: z.string(), + step_id: z.string(), + status: z.enum(['streaming', 'completed']), + text: z.string(), +} as const; + +export const assistantMessageSchema = z.object({ + type: z.literal('assistant'), + ...streamingTextFields, +}); +export type AssistantMessage = z.infer; + +export const thinkingMessageSchema = z.object({ + type: z.literal('thinking'), + ...streamingTextFields, +}); +export type ThinkingMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/base.ts b/packages/kap-server/src/protocol/v2/messages/base.ts new file mode 100644 index 00000000000..7bed3767b17 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/base.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const timestampSchema = z.string(); + +export const timelineBaseFields = { + session_id: z.string(), + agent_id: z.string(), + timestamp: timestampSchema, +}; + +export const sessionBaseFields = { + session_id: z.string(), + timestamp: timestampSchema, +}; + +export const globalBaseFields = { + timestamp: timestampSchema, +}; + +export const stepUsageSchema = z.object({ + input_other: z.number(), + output: z.number(), + input_cache_read: z.number(), + input_cache_creation: z.number(), +}); +export type StepUsage = z.infer; + +export const turnUsageSchema = z.object({ + input_tokens: z.number().optional(), + output_tokens: z.number().optional(), + cached_tokens: z.number().optional(), + cost: z.number().optional(), +}); +export type TurnUsage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/config.ts b/packages/kap-server/src/protocol/v2/messages/config.ts new file mode 100644 index 00000000000..b366b969812 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/config.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { globalBaseFields } from './base'; + +export const configMessageSchema = z.object({ + type: z.literal('config'), + ...globalBaseFields, + config: z.record(z.string(), z.unknown()), + changed_fields: z.array(z.string()).optional(), +}); +export type ConfigMessage = z.infer; + +export const configWarningMessageSchema = z.object({ + type: z.literal('config.warning'), + ...globalBaseFields, + warnings: z.array(z.string()), +}); +export type ConfigWarningMessage = z.infer; + +export const modelCatalogChangedMessageSchema = z.object({ + type: z.literal('model_catalog'), + ...globalBaseFields, +}); +export type ModelCatalogChangedMessage = z.infer; + +export const pluginChangedMessageSchema = z.object({ + type: z.literal('plugin'), + ...globalBaseFields, +}); +export type PluginChangedMessage = z.infer; + +export const capabilityChangedMessageSchema = z.object({ + type: z.literal('capability'), + ...globalBaseFields, + capability_id: z.string().optional(), +}); +export type CapabilityChangedMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/control.ts b/packages/kap-server/src/protocol/v2/messages/control.ts new file mode 100644 index 00000000000..500c92fbab1 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/control.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const helloMessageSchema = z.object({ + type: z.literal('hello'), + protocol_version: z.number(), + server_id: z.string(), + capabilities: z.array(z.string()), +}); +export type HelloMessage = z.infer; + +export const subscribeMessageSchema = z.object({ + type: z.literal('subscribe'), + id: z.number(), + session_id: z.string(), + agent_id: z.string().optional(), + omit: z.array(z.string()).optional(), +}); +export type SubscribeMessage = z.infer; + +export const unsubscribeMessageSchema = z.object({ + type: z.literal('unsubscribe'), + id: z.number(), + session_id: z.string(), +}); +export type UnsubscribeMessage = z.infer; + +export const ackMessageSchema = z.object({ + type: z.literal('ack'), + id: z.number(), + code: z.number(), + msg: z.string().optional(), +}); +export type AckMessage = z.infer; + +export const errorMessageSchema = z.object({ + type: z.literal('error'), + code: z.union([z.string(), z.number()]), + msg: z.string().optional(), +}); +export type ErrorMessage = z.infer; + +export const clientMessageSchema = z.discriminatedUnion('type', [ + subscribeMessageSchema, + unsubscribeMessageSchema, +]); +export type ClientMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/delta.ts b/packages/kap-server/src/protocol/v2/messages/delta.ts new file mode 100644 index 00000000000..7bb6d42b6cc --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/delta.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; + +export const toolProgressPayloadSchema = z.object({ + kind: z.enum(['stdout', 'stderr', 'progress', 'status', 'custom']), + text: z.string().optional(), + percent: z.number().optional(), + custom_kind: z.string().optional(), + custom_data: z.unknown().optional(), +}); +export type ToolProgressPayload = z.infer; + +export const assistantDeltaMessageSchema = z.object({ + type: z.literal('assistant.delta'), + ...timelineBaseFields, + message_id: z.string(), + text: z.string(), +}); +export type AssistantDeltaMessage = z.infer; + +export const thinkingDeltaMessageSchema = z.object({ + type: z.literal('thinking.delta'), + ...timelineBaseFields, + message_id: z.string(), + text: z.string(), +}); +export type ThinkingDeltaMessage = z.infer; + +export const toolCallDeltaMessageSchema = z.object({ + type: z.literal('tool_call.delta'), + ...timelineBaseFields, + tool_call_id: z.string(), + input_text: z.string(), +}); +export type ToolCallDeltaMessage = z.infer; + +export const toolProgressMessageSchema = z.object({ + type: z.literal('tool.progress'), + ...timelineBaseFields, + tool_call_id: z.string(), + progress: toolProgressPayloadSchema, +}); +export type ToolProgressMessage = z.infer; + +export const deltaMessageSchema = z.discriminatedUnion('type', [ + assistantDeltaMessageSchema, + thinkingDeltaMessageSchema, + toolCallDeltaMessageSchema, + toolProgressMessageSchema, +]); +export type DeltaMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/entity-id.ts b/packages/kap-server/src/protocol/v2/messages/entity-id.ts new file mode 100644 index 00000000000..e09dcd3e8e3 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/entity-id.ts @@ -0,0 +1,30 @@ +import type { ServerMessage } from './index'; + +export function entityId(msg: ServerMessage): string { + const m = msg as { + message_id?: string; + tool_call_id?: string; + interaction_id?: string; + task_id?: string; + todo_id?: string; + system_id?: string; + step_id?: string; + turn_id?: string; + }; + return ( + m.message_id ?? + m.tool_call_id ?? + m.interaction_id ?? + m.task_id ?? + m.todo_id ?? + m.system_id ?? + m.step_id ?? + m.turn_id ?? + '' + ); +} + +export function entityKey(msg: ServerMessage): string { + const agent = (msg as { agent_id?: string }).agent_id ?? ''; + return `${agent}:${msg.type}:${entityId(msg)}`; +} diff --git a/packages/kap-server/src/protocol/v2/messages/index.ts b/packages/kap-server/src/protocol/v2/messages/index.ts new file mode 100644 index 00000000000..0da71941c88 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/index.ts @@ -0,0 +1,91 @@ +import { z } from 'zod'; + +import { turnMessageSchema } from './turn'; +import { stepMessageSchema } from './step'; +import { userMessageSchema } from './user'; +import { assistantMessageSchema, thinkingMessageSchema } from './assistant'; +import { + assistantDeltaMessageSchema, + thinkingDeltaMessageSchema, + toolCallDeltaMessageSchema, + toolProgressMessageSchema, +} from './delta'; +import { toolCallMessageSchema } from './tool-call'; +import { interactionMessageSchema } from './interaction'; +import { taskMessageSchema } from './task'; +import { todoMessageSchema } from './todo'; +import { systemMessageSchema } from './system'; +import { sessionMessageSchema, sessionStateMessageSchema } from './session-state'; +import { workspaceMessageSchema } from './workspace'; +import { + capabilityChangedMessageSchema, + configMessageSchema, + configWarningMessageSchema, + modelCatalogChangedMessageSchema, + pluginChangedMessageSchema, +} from './config'; +import { ackMessageSchema, errorMessageSchema, helloMessageSchema } from './control'; + +export * from './base'; +export * from './turn'; +export * from './step'; +export * from './user'; +export * from './assistant'; +export * from './delta'; +export * from './tool-call'; +export * from './interaction'; +export * from './task'; +export * from './todo'; +export * from './system'; +export * from './session-state'; +export * from './workspace'; +export * from './config'; +export * from './control'; +export * from './entity-id'; + +export const serverMessageSchema = z.discriminatedUnion('type', [ + turnMessageSchema, + stepMessageSchema, + userMessageSchema, + assistantMessageSchema, + thinkingMessageSchema, + assistantDeltaMessageSchema, + thinkingDeltaMessageSchema, + toolCallMessageSchema, + toolCallDeltaMessageSchema, + toolProgressMessageSchema, + systemMessageSchema, + interactionMessageSchema, + taskMessageSchema, + todoMessageSchema, + sessionStateMessageSchema, + sessionMessageSchema, + workspaceMessageSchema, + configMessageSchema, + configWarningMessageSchema, + modelCatalogChangedMessageSchema, + pluginChangedMessageSchema, + capabilityChangedMessageSchema, + helloMessageSchema, + ackMessageSchema, + errorMessageSchema, +]); +export type ServerMessage = z.infer; + +export class ContractViolation extends Error { + readonly issues: z.core.$ZodIssue[]; + readonly raw: unknown; + + constructor(issues: z.core.$ZodIssue[], raw: unknown) { + super(`ws2 contract violation: ${issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`); + this.name = 'ContractViolation'; + this.issues = issues; + this.raw = raw; + } +} + +export function parseServerMessage(raw: unknown): ServerMessage { + const result = serverMessageSchema.safeParse(raw); + if (!result.success) throw new ContractViolation(result.error.issues, raw); + return result.data; +} diff --git a/packages/kap-server/src/protocol/v2/messages/interaction.ts b/packages/kap-server/src/protocol/v2/messages/interaction.ts new file mode 100644 index 00000000000..1b398d1ffe5 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/interaction.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; + +export const approvalRequestSchema = z.object({ + tool_name: z.string(), + input: z.unknown().optional(), + reason: z.string().optional(), + display: z.unknown().optional(), +}); +export type ApprovalRequest = z.infer; + +export const approvalResponseSchema = z.object({ + decision: z.enum(['approved', 'rejected', 'cancelled']), + feedback: z.string().optional(), +}); +export type ApprovalResponsePayload = z.infer; + +export const questionItemSchema = z.object({ + id: z.string(), + question: z.string(), + options: z.array(z.string()).optional(), +}); +export type QuestionItem = z.infer; + +export const questionRequestSchema = z.object({ + questions: z.array(questionItemSchema), +}); +export type QuestionRequest = z.infer; + +export const questionResponseSchema = z.object({ + answers: z.record(z.string(), z.string()), +}); +export type QuestionResponsePayload = z.infer; + +const interactionBase = { + ...timelineBaseFields, + interaction_id: z.string(), + state: z.enum(['pending', 'approved', 'rejected', 'cancelled', 'answered', 'dismissed']), + tool_call_id: z.string().optional(), +} as const; + +export const approvalInteractionMessageSchema = z.object({ + type: z.literal('interaction'), + ...interactionBase, + kind: z.literal('approval'), + request: approvalRequestSchema.optional(), + response: approvalResponseSchema.optional(), +}); +export type ApprovalInteractionMessage = z.infer; + +export const questionInteractionMessageSchema = z.object({ + type: z.literal('interaction'), + ...interactionBase, + kind: z.literal('question'), + request: questionRequestSchema.optional(), + response: questionResponseSchema.optional(), +}); +export type QuestionInteractionMessage = z.infer; + +export const interactionMessageSchema = z.discriminatedUnion('kind', [ + approvalInteractionMessageSchema, + questionInteractionMessageSchema, +]); +export type InteractionMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/session-state.ts b/packages/kap-server/src/protocol/v2/messages/session-state.ts new file mode 100644 index 00000000000..f47689941e0 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/session-state.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +import { globalBaseFields, sessionBaseFields, stepUsageSchema } from './base'; + +export const agentPhaseSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('idle') }), + z.object({ + kind: z.literal('running'), + turn_id: z.number(), + step: z.number(), + step_id: z.string().optional(), + since: z.number(), + }), + z.object({ + kind: z.literal('awaiting_approval'), + turn_id: z.number(), + step: z.number(), + since: z.number(), + }), + z.object({ + kind: z.literal('awaiting_question'), + turn_id: z.number(), + step: z.number(), + since: z.number(), + }), +]); +export type AgentPhase = z.infer; + +export const sessionGoalSchema = z.object({ + objective: z.string(), + status: z.enum(['active', 'paused', 'blocked', 'complete']), + completion_criterion: z.string().optional(), + budget_used: z.number().optional(), + budget_limit: z.number().optional(), +}); +export type SessionGoal = z.infer; + +export const sessionModesSchema = z.object({ + plan: z + .object({ + review_path: z.string().optional(), + version: z.number().optional(), + }) + .optional(), + swarm: z.object({ trigger: z.string().optional() }).optional(), +}); +export type SessionModes = z.infer; + +export const sessionStateUsageSchema = z.object({ + by_model: z.record(z.string(), stepUsageSchema).optional(), + current_turn: stepUsageSchema.optional(), + total: stepUsageSchema.optional(), +}); +export type SessionStateUsage = z.infer; + +export const sessionStateMessageSchema = z.object({ + type: z.literal('session.state'), + ...sessionBaseFields, + busy: z.boolean(), + main_turn_active: z.boolean(), + pending_interaction: z.enum(['none', 'approval', 'question']).optional(), + last_turn_reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']).optional(), + activity: z.enum(['idle', 'turn', 'disposing', 'unknown']), + phase: agentPhaseSchema.optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), + permission: z.enum(['manual', 'yolo', 'auto']).optional(), + usage: sessionStateUsageSchema.optional(), + context_tokens: z.number().optional(), + max_context_tokens: z.number().optional(), + context_usage: z.number().optional(), + goal: sessionGoalSchema.optional(), + modes: sessionModesSchema.optional(), +}); +export type SessionStateMessage = z.infer; + +export const sessionInfoSchema = z.object({ + session_id: z.string(), + workspace_id: z.string().optional(), + title: z.string(), + status: z.string(), + model: z.string().optional(), + created_at: z.string(), + updated_at: z.string().optional(), + turn_count: z.number().optional(), +}); +export type SessionInfo = z.infer; + +export const sessionMessageSchema = z.object({ + type: z.literal('session'), + ...globalBaseFields, + subtype: z.enum(['created', 'updated', 'archived', 'deleted']), + session: sessionInfoSchema, + changed_fields: z.array(z.string()).optional(), +}); +export type SessionMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/step.ts b/packages/kap-server/src/protocol/v2/messages/step.ts new file mode 100644 index 00000000000..5a857614787 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/step.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { stepUsageSchema, timelineBaseFields } from './base'; + +export const stepRetrySchema = z.object({ + failed_attempt: z.number(), + next_attempt: z.number(), + max_attempts: z.number(), + delay_ms: z.number(), + error_name: z.string(), + error_message: z.string(), + status_code: z.number().optional(), +}); +export type StepRetry = z.infer; + +export const stepTimingSchema = z.object({ + llm_first_token_ms: z.number().optional(), + llm_stream_duration_ms: z.number().optional(), +}); +export type StepTiming = z.infer; + +export const stepMessageSchema = z.object({ + type: z.literal('step'), + ...timelineBaseFields, + step_id: z.string(), + turn_id: z.string(), + ordinal: z.number(), + state: z.enum(['running', 'completed', 'interrupted', 'failed']), + started_at: z.string().optional(), + ended_at: z.string().optional(), + usage: stepUsageSchema.optional(), + finish_reason: z.string().optional(), + timing: stepTimingSchema.optional(), + retry: stepRetrySchema.optional(), + end_reason: z.string().optional(), + end_message: z.string().optional(), +}); +export type StepMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/system.ts b/packages/kap-server/src/protocol/v2/messages/system.ts new file mode 100644 index 00000000000..3230db0bb51 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/system.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; + +const openPayload = z.record(z.string(), z.unknown()).optional(); + +const systemBase = { + type: z.literal('system'), + ...timelineBaseFields, + system_id: z.string(), + at: z.string().optional(), +} as const; + +export const compactionSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('compaction'), + payload: z.object({ + before_tokens: z.number(), + after_tokens: z.number(), + summarized_through_turn: z.string().optional(), + }), +}); + +export const undoSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('undo'), + payload: z.object({ undo_turn_id: z.string() }), +}); + +export const clearSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('clear'), + payload: openPayload, +}); + +export const goalSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('goal'), + payload: z.object({ + status: z.string(), + objective: z.string().optional(), + }), +}); + +export const planEnterSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('plan.enter'), + payload: z.object({ mode: z.string() }).optional(), +}); + +export const planExitSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('plan.exit'), + payload: z.object({ + approved: z.boolean(), + version: z.number().optional(), + key: z.string().optional(), + }), +}); + +export const planRevisionSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('plan.revision'), + payload: z.object({ + version: z.number(), + key: z.string().optional(), + summary: z.string().optional(), + }), +}); + +export const swarmEnterSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('swarm.enter'), + payload: openPayload, +}); + +export const swarmExitSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('swarm.exit'), + payload: openPayload, +}); + +export const skillSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('skill'), + payload: z.object({ + skill_name: z.string().optional(), + status: z.string().optional(), + }).optional(), +}); + +export const noticeSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('notice'), + payload: z.object({ + severity: z.enum(['info', 'warning', 'error']).optional(), + message: z.string().optional(), + }).optional(), +}); + +export const hookSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('hook'), + payload: openPayload, +}); + +export const interruptionSystemMessageSchema = z.object({ + ...systemBase, + subtype: z.literal('interruption'), + payload: z.object({ + reason: z.string(), + turn_id: z.string().optional(), + }), +}); + +export const systemMessageSchema = z.discriminatedUnion('subtype', [ + compactionSystemMessageSchema, + undoSystemMessageSchema, + clearSystemMessageSchema, + goalSystemMessageSchema, + planEnterSystemMessageSchema, + planExitSystemMessageSchema, + planRevisionSystemMessageSchema, + swarmEnterSystemMessageSchema, + swarmExitSystemMessageSchema, + skillSystemMessageSchema, + noticeSystemMessageSchema, + hookSystemMessageSchema, + interruptionSystemMessageSchema, +]); +export type SystemMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/task.ts b/packages/kap-server/src/protocol/v2/messages/task.ts new file mode 100644 index 00000000000..8cdc89abb33 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/task.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { stepUsageSchema, timelineBaseFields } from './base'; + +export const taskMessageSchema = z.object({ + type: z.literal('task'), + ...timelineBaseFields, + task_id: z.string(), + kind: z.enum(['shell', 'subagent', 'tool', 'other']), + state: z.enum(['running', 'completed', 'failed', 'timed_out', 'killed', 'lost']), + detached: z.boolean(), + description: z.string().optional(), + child_agent_id: z.string().optional(), + output_tail: z.string(), + started_at: z.string().optional(), + ended_at: z.string().optional(), + result_summary: z.string().optional(), + error: z.string().optional(), + state_reason: z.string().optional(), + usage: stepUsageSchema.optional(), + model: z.string().optional(), + thinking_effort: z.string().optional(), +}); +export type TaskMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/todo.ts b/packages/kap-server/src/protocol/v2/messages/todo.ts new file mode 100644 index 00000000000..88eccb0b7b2 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/todo.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; + +export const todoItemSchema = z.object({ + title: z.string(), + status: z.enum(['pending', 'in_progress', 'done']), +}); +export type TodoItem = z.infer; + +export const todoMessageSchema = z.object({ + type: z.literal('todo'), + ...timelineBaseFields, + todo_id: z.string(), + items: z.array(todoItemSchema), + updated_at: z.string().optional(), +}); +export type TodoMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/tool-call.ts b/packages/kap-server/src/protocol/v2/messages/tool-call.ts new file mode 100644 index 00000000000..c0770337fe2 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/tool-call.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; +import { toolProgressPayloadSchema } from './delta'; + +export const toolCallAgentRefSchema = z.object({ + agent_id: z.string(), + role: z.enum(['child', 'member']).optional(), +}); +export type ToolCallAgentRef = z.infer; + +export const toolCallMessageSchema = z.object({ + type: z.literal('tool_call'), + ...timelineBaseFields, + tool_call_id: z.string(), + turn_id: z.string(), + step_id: z.string(), + name: z.string(), + view: z.string().optional(), + state: z.enum(['running', 'done', 'error']), + input: z.unknown().optional(), + input_text: z.string().optional(), + output: z.unknown().optional(), + display: z.unknown().optional(), + error: z.string().optional(), + progress: toolProgressPayloadSchema.optional(), + task_id: z.string().optional(), + approval_id: z.string().optional(), + todo_id: z.string().optional(), + agent_refs: z.array(toolCallAgentRefSchema).optional(), +}); +export type ToolCallMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/turn.ts b/packages/kap-server/src/protocol/v2/messages/turn.ts new file mode 100644 index 00000000000..5ed1c92b764 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/turn.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { timelineBaseFields, turnUsageSchema } from './base'; + +export const turnOriginSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('user') }), + z.object({ kind: z.literal('cron'), cron_id: z.string(), schedule: z.string().optional() }), + z.object({ kind: z.literal('task'), task_id: z.string() }), + z.object({ kind: z.literal('skill'), skill_name: z.string().optional() }), + z.object({ kind: z.literal('hook'), name: z.string().optional() }), + z.object({ kind: z.literal('compaction') }), + z.object({ kind: z.literal('side') }), + z.object({ kind: z.literal('goal') }), + z.object({ kind: z.literal('other'), name: z.string().optional() }), +]); +export type TurnOrigin = z.infer; + +export const turnMessageSchema = z.object({ + type: z.literal('turn'), + ...timelineBaseFields, + turn_id: z.string(), + ordinal: z.number(), + state: z.enum(['running', 'completed']), + origin: turnOriginSchema, + user_message_id: z.string().optional(), + attachment_ids: z.array(z.string()).optional(), + started_at: z.string().optional(), + ended_at: z.string().optional(), + usage: turnUsageSchema.optional(), + duration_ms: z.number().optional(), +}); +export type TurnMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/user.ts b/packages/kap-server/src/protocol/v2/messages/user.ts new file mode 100644 index 00000000000..1ca47f676a0 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/user.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; +import { timelineBaseFields } from './base'; + +export const userMessageOriginSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('cron'), cron_id: z.string(), schedule: z.string() }), + z.object({ kind: z.literal('channel'), channel_id: z.string() }), + z.object({ kind: z.literal('task'), task_id: z.string() }), + z.object({ + kind: z.literal('skill'), + skill_name: z.string(), + args: z.string().optional(), + trigger: z.string().optional(), + }), +]); +export type UserMessageOrigin = z.infer; + +export const taskNotificationPayloadSchema = z.object({ + title: z.string(), + body: z.string(), + severity: z.string().optional(), + type: z.string().optional(), + source_kind: z.string().optional(), + source_id: z.string().optional(), + agent_id: z.string().optional(), + raw: z.string().optional(), +}); +export type TaskNotificationPayload = z.infer; + +export const skillActivationSchema = z.object({ + skill_name: z.string(), + skill_args: z.string().optional(), +}); +export type SkillActivation = z.infer; + +export const userMessageSchema = z.object({ + type: z.literal('user'), + ...timelineBaseFields, + message_id: z.string(), + turn_id: z.string(), + step_id: z.string().optional(), + text: z.string(), + attachment_ids: z.array(z.string()).optional(), + skill_activations: z.array(skillActivationSchema).optional(), + status: z.enum(['running', 'completed']), + created_at: z.string(), + finished_at: z.string().optional(), + steered_at: z.string().optional(), + origin: userMessageOriginSchema.optional(), + notification: taskNotificationPayloadSchema.optional(), +}); +export type UserMessage = z.infer; diff --git a/packages/kap-server/src/protocol/v2/messages/workspace.ts b/packages/kap-server/src/protocol/v2/messages/workspace.ts new file mode 100644 index 00000000000..d41cf2ea0c2 --- /dev/null +++ b/packages/kap-server/src/protocol/v2/messages/workspace.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { globalBaseFields } from './base'; + +export const workspaceInfoSchema = z.object({ + id: z.string(), + root: z.string(), + name: z.string(), + created_at: z.string(), + last_opened_at: z.string(), + session_count: z.number(), +}); +export type WorkspaceInfo = z.infer; + +export const workspaceMessageSchema = z.object({ + type: z.literal('workspace'), + ...globalBaseFields, + subtype: z.enum(['created', 'updated', 'deleted']), + workspace: workspaceInfoSchema, +}); +export type WorkspaceMessage = z.infer; diff --git a/packages/kap-server/src/routes/history.ts b/packages/kap-server/src/routes/history.ts new file mode 100644 index 00000000000..d24f4f97928 --- /dev/null +++ b/packages/kap-server/src/routes/history.ts @@ -0,0 +1,107 @@ +import { isPlainAgentId } from '@moonshot-ai/transcript'; +import { z } from 'zod'; + +import { errEnvelope, okEnvelope } from '../envelope'; +import { ErrorCode } from '../protocol/error-codes'; +import { serverMessageSchema } from '../protocol/v2/messages/index'; +import { defineRoute } from '../middleware/defineRoute'; +import { buildColdHistory, type ColdWireRecord } from '../services/v2Projection/coldHistory'; + +export interface HistoryRouteSource { + readColdWireRecords(sessionId: string, agentId?: string): Promise; +} + +interface HistoryRouteHost { + get( + path: string, + options: { preHandler: unknown[]; schema?: Record } | undefined, + handler: ( + req: { id: string; query: unknown; params: unknown }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; +} + +const sessionIdParamSchema = z.object({ + session_id: z.string().min(1), +}); + +const historyQueryCoercion = z + .object({ + agent_id: z.string().min(1).optional(), + before_turn: z.string().min(1).optional(), + after_step: z.string().min(1).optional(), + page_size: z.coerce.number().int().min(1).max(200).optional(), + }) + .superRefine((value, ctx) => { + if (value.before_turn !== undefined && value.after_step !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'before_turn and after_step are mutually exclusive', + path: ['before_turn'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (value.agent_id !== undefined && !isPlainAgentId(value.agent_id)) { + ctx.addIssue({ + code: 'custom', + message: 'agent_id must be a plain agent id (no path separators)', + path: ['agent_id'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +export const historyResponseSchema = z.object({ + session_id: z.string(), + items: z.array(serverMessageSchema), + has_more: z.boolean(), + in_flight: z + .object({ + turn_id: z.string(), + step_id: z.string().optional(), + }) + .nullable(), +}); + +const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +export interface HistoryRouteDeps { + readonly transcript: HistoryRouteSource; +} + +export function registerHistoryRoutes(app: HistoryRouteHost, deps: HistoryRouteDeps): void { + const route = defineRoute( + { + method: 'GET', + path: '/sessions/{session_id}/history', + params: sessionIdParamSchema, + querystring: historyQueryCoercion, + success: { data: historyResponseSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + [ErrorCode.SESSION_NOT_FOUND]: {}, + }, + description: + 'Cold-rebuilt terminal-entity history page for a session agent: wire.jsonl records are folded into the same message shapes the WS stream emits, grouped turn → step with keyset paging (before_turn pages older, after_step catches the tail up) and an in_flight marker for the unfinished turn', + tags: ['history'], + }, + async (req, reply) => { + const { session_id } = req.params; + const query = req.query; + const agentId = query.agent_id ?? 'main'; + const records = await deps.transcript.readColdWireRecords(session_id, agentId); + if (records === undefined) { + reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} not found`, req.id)); + return; + } + const page = buildColdHistory(session_id, agentId, records, { + beforeTurn: query.before_turn, + afterStep: query.after_step, + pageSize: query.page_size, + }); + reply.send(okEnvelope(page, req.id)); + }, + ); + app.get(route.path, route.options, route.handler as Parameters[2]); +} diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index daa9ec6d5dd..6bdab82faa9 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -346,13 +346,23 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { eventService: core.accessor.get(IEventService), sessionId: session_id, }, promptMetadataTextFromContentParts(parts)); - const handle = await reservation.submit({ - role: 'user', - content: parts, - toolCalls: [], - origin: { kind: 'user', attachments: promptAttachments }, - }); + const handle = await reservation.submit( + { + role: 'user', + content: parts, + toolCalls: [], + origin: { kind: 'user', attachments: promptAttachments }, + }, + req.body.steer === true ? { steer: true } : undefined, + ); enqueued = true; + if (req.body.steer === true && handle.state === 'pending') { + try { + await resolved.prompt.steer([handle.id]); + } catch (error) { + if (!isError2(error) || error.code !== ErrorCodes.PROMPT_NOT_FOUND) throw error; + } + } const staging = preparedMedia; void Promise.race([handle.launched, handle.completion]).then( () => staging?.discard(), diff --git a/packages/kap-server/src/routes/registerApiV1Routes.ts b/packages/kap-server/src/routes/registerApiV1Routes.ts index 992a0b2a863..5b5430de47a 100644 --- a/packages/kap-server/src/routes/registerApiV1Routes.ts +++ b/packages/kap-server/src/routes/registerApiV1Routes.ts @@ -19,6 +19,7 @@ import { registerFileHistoryRoutes } from './fileHistory'; import { registerFilesRoutes } from './files'; import { registerFsRoutes } from './fs'; import { registerGuiStoreRoutes } from './guiStore'; +import { registerHistoryRoutes } from './history'; import { registerMessagesRoutes } from './messages'; import type { IGuiStoreService } from '../services/guiStore/guiStore'; import { registerDebugRoutes } from '../transport/registerDebugRoutes'; @@ -73,6 +74,7 @@ export interface RegisterApiV1RoutesOptions { readonly pluginMarketplaceIsDefault: boolean; readonly dangerousBypassAuth?: boolean; readonly webTitle?: string; + readonly serverId?: string; } export async function registerApiV1Routes( @@ -90,7 +92,7 @@ export async function registerApiV1Routes( registerMetaRoute(apiV1, { serverVersion: opts.serverVersion, - serverId: ulid(), + serverId: opts.serverId ?? ulid(), startedAt: new Date().toISOString(), dangerousBypassAuth: opts.dangerousBypassAuth === true, webTitle: opts.webTitle, @@ -192,6 +194,9 @@ export async function registerApiV1Routes( core, transcriptService: opts.transcriptService, }); + registerHistoryRoutes(apiV1 as unknown as Parameters[0], { + transcript: opts.transcriptService, + }); if (opts.enableShutdown !== false) { registerShutdownRoutes(apiV1 as unknown as Parameters[0], { onShutdown: opts.onShutdown, diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 887f1c1c90f..527019ef3dc 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -422,8 +422,27 @@ export class TranscriptService { this.dispatchOps(sessionId, { agentId, ops }); } - async readColdRoster(sessionId: string): Promise { + async readColdWireRecords(sessionId: string, agentId: string = MAIN_AGENT_ID): Promise { const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + const wirePath = join( + this.deps.homeDir, + SESSIONS_ROOT, + summary.workspaceId, + sessionId, + AGENTS_DIR, + agentId, + WIRE_FILE, + ); + try { + return await readWireRecords(wirePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + } + + async readColdRoster(sessionId: string): Promise { const summary = await this.deps.core.accessor.get(ISessionIndex).get(sessionId); if (summary === undefined) return undefined; let meta: SessionMeta; try { diff --git a/packages/kap-server/src/services/v2Projection/agentProjector.ts b/packages/kap-server/src/services/v2Projection/agentProjector.ts new file mode 100644 index 00000000000..14bc12e8a3d --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/agentProjector.ts @@ -0,0 +1,1473 @@ +import type { + ApprovalRequest, + ApprovalResponsePayload, + AssistantDeltaMessage, + AssistantMessage, + InteractionMessage, + QuestionRequest, + QuestionResponsePayload, + ServerMessage, + SkillActivation, + StepMessage, + StepRetry, + StepUsage, + TaskMessage, + TaskNotificationPayload, + ThinkingDeltaMessage, + ThinkingMessage, + TodoItem, + TodoMessage, + ToolCallAgentRef, + ToolCallMessage, + ToolProgressPayload, + TurnMessage, + TurnOrigin, + UserMessage, + UserMessageOrigin, +} from '../../protocol/v2/messages/index'; + +export interface ProjectionEvent { + type: string; + time?: number; + agentId?: string; + promptId?: unknown; + promptIds?: unknown; + status?: unknown; + steer?: unknown; + content?: unknown; + origin?: unknown; + createdAt?: unknown; + steeredAt?: unknown; + finishedAt?: unknown; + abortedAt?: unknown; + turnId?: unknown; + durationMs?: unknown; + step?: unknown; + usage?: unknown; + finishReason?: unknown; + llmFirstTokenLatencyMs?: unknown; + llmStreamDurationMs?: unknown; + reason?: unknown; + message?: unknown; + failedAttempt?: unknown; + nextAttempt?: unknown; + maxAttempts?: unknown; + delayMs?: unknown; + errorName?: unknown; + errorMessage?: unknown; + statusCode?: unknown; + delta?: unknown; + toolCallId?: unknown; + argumentsPart?: unknown; + name?: unknown; + args?: unknown; + display?: unknown; + update?: unknown; + output?: unknown; + isError?: unknown; + key?: unknown; + value?: unknown; + info?: unknown; + outputTail?: unknown; + id?: unknown; + toolName?: unknown; + action?: unknown; + toolInput?: unknown; + decision?: unknown; + feedback?: unknown; + result?: unknown; + turns?: unknown; + fromTurnId?: unknown; + taskId?: unknown; + attachmentIds?: unknown; + prompt?: unknown; + summary?: unknown; + planMode?: unknown; + version?: unknown; + snapshot?: unknown; + agentRefs?: unknown; + resultSummary?: unknown; + lifecycle?: unknown; + turn?: unknown; + endedAt?: unknown; + model?: unknown; + thinkingEffort?: unknown; + contextTokens?: unknown; + maxContextTokens?: unknown; + mode?: unknown; + hookEvent?: unknown; + blocked?: unknown; + skillName?: unknown; + commandName?: unknown; + swarmMode?: unknown; + [key: string]: unknown; +} + +export interface InteractionPendingRecord { + id: string; + kind: 'approval' | 'question'; + toolCallId?: string; + request: ApprovalRequest | QuestionRequest; + time?: number; +} + +export interface InteractionResolvedRecord { + id: string; + state: 'approved' | 'rejected' | 'cancelled' | 'answered' | 'dismissed'; + response?: ApprovalResponsePayload | QuestionResponsePayload; + time?: number; +} + +interface TurnAcc { + turnId: string; + engineTurnId: number; + origin: TurnOrigin; + state: 'running' | 'completed'; + startedAt: string; + userSeq: number; + promptIds: string[]; + userMessageId?: string; + attachmentIds?: string[]; + usageInput: number; + usageOutput: number; +} + +interface PromptAcc { + promptId: string; + messageId: string; + turnId: string; + text: string; + createdAt: string; + status: 'running' | 'completed'; + queued: boolean; + steerHeld: boolean; + emitted: boolean; + origin?: UserMessageOrigin; + attachmentIds?: string[]; + steeredAt?: string; + notification?: TaskNotificationPayload; + skillActivations?: SkillActivation[]; +} + +interface StepAcc { + stepId: string; + turnId: string; + engineTurnId: number; + ordinal: number; + state: 'running' | 'completed' | 'interrupted' | 'failed'; + startedAt: string; + endedAt?: number; + usage?: StepUsage; + finishReason?: string; + retry?: StepRetry; + endReason?: string; + endMessage?: string; + timing?: { llm_first_token_ms?: number; llm_stream_duration_ms?: number }; + textSeq: { a: number; h: number }; +} + +interface TextAcc { + kind: 'assistant' | 'thinking'; + messageId: string; + stepKey: string; + turnId: string; + stepId: string; + text: string; + announced: boolean; +} + +interface ToolAcc { + toolCallId: string; + turnId: string; + stepId: string; + name: string; + state: 'running' | 'done' | 'error'; + input?: unknown; + inputText?: string; + output?: unknown; + display?: unknown; + error?: string; + progress?: ToolProgressPayload; + approvalId?: string; + taskId?: string; + todoId?: string; + agentRefs?: ToolCallAgentRef[]; + opened: boolean; +} + +interface TaskAcc { + taskId: string; + message: TaskMessage; +} + +interface TaskInfoPayload { + taskId?: unknown; + kind?: unknown; + status?: unknown; + detached?: unknown; + description?: unknown; + startedAt?: unknown; + model?: unknown; + thinkingEffort?: unknown; + endedAt?: unknown; + resultSummary?: unknown; + error?: unknown; + stopReason?: unknown; + usage?: unknown; + outputTail?: unknown; + childAgentId?: unknown; +} + +interface InteractionAcc { + id: string; + kind: 'approval' | 'question'; + toolCallId?: string; + request: ApprovalRequest | QuestionRequest; + resolved?: boolean; +} + +const MAIN_FALLBACK_TIME = 0; + +function iso(time: number | undefined): string { + return new Date(time ?? MAIN_FALLBACK_TIME).toISOString(); +} + +export function textFromContent(content: unknown): string { + if (!Array.isArray(content)) return ''; + return content + .filter((part): part is { type: 'text'; text: string } => { + const p = part as { type?: string; text?: unknown }; + return p?.type === 'text' && typeof p.text === 'string'; + }) + .map((part) => part.text) + .join(''); +} + +export function toTurnOrigin(origin: unknown): TurnOrigin { + const o = origin as { kind?: string } | undefined; + switch (o?.kind) { + case 'cron_job': { + const c = o as { jobId?: string; cron?: string }; + return { kind: 'cron', cron_id: c.jobId ?? '', schedule: c.cron }; + } + case 'task': + return { kind: 'task', task_id: (o as { taskId?: string }).taskId ?? '' }; + case 'skill_activation': + return { kind: 'skill', skill_name: (o as { skillName?: string }).skillName }; + case 'hook_result': + return { kind: 'hook', name: (o as { event?: string }).event }; + case 'compaction_summary': + return { kind: 'compaction' }; + case 'system_trigger': { + const name = (o as { name?: string }).name; + if (name === 'goal_continuation') return { kind: 'goal' }; + return { kind: 'other', name }; + } + case 'side': + return { kind: 'side' }; + case 'user': + case undefined: + return { kind: 'user' }; + default: + return { kind: 'other', name: o?.kind }; + } +} + +export function toUserOrigin(origin: unknown): UserMessageOrigin | undefined { + const o = origin as + | { kind?: string; jobId?: string; cron?: string; taskId?: string; skillName?: string; skillArgs?: string; trigger?: string } + | undefined; + if (o?.kind === 'cron_job') return { kind: 'cron', cron_id: o.jobId ?? '', schedule: o.cron ?? '' }; + if (o?.kind === 'task') return { kind: 'task', task_id: o.taskId ?? '' }; + if (o?.kind === 'skill_activation') { + return { kind: 'skill', skill_name: o.skillName ?? '', args: o.skillArgs, trigger: o.trigger }; + } + return undefined; +} + +export function toSkillActivations(origin: unknown): SkillActivation[] | undefined { + const list = (origin as { skillActivations?: unknown } | undefined)?.skillActivations; + if (!Array.isArray(list) || list.length === 0) return undefined; + const out: SkillActivation[] = []; + for (const item of list) { + const name = (item as { skillName?: unknown } | undefined)?.skillName; + if (typeof name !== 'string' || name.length === 0) continue; + const args = (item as { skillArgs?: unknown } | undefined)?.skillArgs; + out.push({ skill_name: name, skill_args: typeof args === 'string' ? args : undefined }); + } + return out.length > 0 ? out : undefined; +} + +export function toStepUsage(usage: unknown): StepUsage | undefined { + const u = usage as + | { inputOther?: number; output?: number; inputCacheRead?: number; inputCacheCreation?: number } + | undefined; + if (!u) return undefined; + return { + input_other: u.inputOther ?? 0, + output: u.output ?? 0, + input_cache_read: u.inputCacheRead ?? 0, + input_cache_creation: u.inputCacheCreation ?? 0, + }; +} + +function toToolProgress(update: unknown): ToolProgressPayload { + const u = update as + | { kind?: ToolProgressPayload['kind']; text?: string; percent?: number; customKind?: string; customData?: unknown } + | undefined; + return { + kind: u?.kind ?? 'custom', + text: u?.text, + percent: u?.percent, + custom_kind: u?.customKind, + custom_data: u?.customData, + }; +} + +export class AgentV2Projector { + private maxTurnId = -1; + private readonly turns = new Map(); + private readonly prompts = new Map(); + private readonly queue: string[] = []; + private readonly steps = new Map(); + private currentStep?: StepAcc; + private openAssistant?: TextAcc; + private openThinking?: TextAcc; + private readonly tools = new Map(); + private readonly tasks = new Map(); + private readonly interactions = new Map(); + private todoId?: string; + private todoSeq = 0; + private todoUpdatedAt?: number; + private lastTodoItems?: TodoItem[]; + private systemSeq = 0; + private planVersion?: number; + private planKey?: string; + private planExitApproved?: boolean; + + constructor( + private readonly sessionId: string, + private readonly agentId: string, + private readonly turnIdPrefix = 't', + ) {} + + apply(event: ProjectionEvent): ServerMessage[] { + const out: ServerMessage[] = []; + switch (event.type) { + case 'prompt.submitted': this.onPromptSubmitted(event, out); break; + case 'prompt.steered': this.onPromptSteered(event, out); break; + case 'turn.steer': this.onTurnSteer(event, out); break; + case 'prompt.started': this.onPromptStarted(event, out); break; + case 'prompt.completed': this.onPromptCompleted(event, out); break; + case 'prompt.aborted': this.onPromptAborted(event, out); break; + case 'turn.started': this.onTurnStarted(event, out); break; + case 'turn.ended': this.onTurnEnded(event, out); break; + case 'turn.step.started': this.onStepStarted(event, out); break; + case 'turn.step.completed': this.onStepCompleted(event, out); break; + case 'turn.step.interrupted': this.onStepInterrupted(event, out); break; + case 'turn.step.retrying': this.onStepRetrying(event, out); break; + case 'assistant.delta': this.onTextDelta(event, 'assistant', out); break; + case 'thinking.delta': this.onTextDelta(event, 'thinking', out); break; + case 'tool.call.delta': this.onToolCallDelta(event, out); break; + case 'tool.call.started': this.onToolCallStarted(event, out); break; + case 'tool.progress': this.onToolProgress(event, out); break; + case 'tool.result': this.onToolResult(event, out); break; + case 'task.started': this.onTaskStarted(event, out); break; + case 'task.terminated': this.onTaskTerminated(event, out); break; + case 'task.notified': this.onTaskNotified(event, out); break; + case 'tools.update_store': this.onToolsUpdateStore(event, out); break; + case 'permission.approval.requested': this.onApprovalRequested(event, out); break; + case 'permission.approval.resolved': this.onApprovalResolved(event, out); break; + case 'compaction.completed': this.onCompactionCompleted(event, out); break; + case 'context.undone': this.onContextUndone(event, out); break; + case 'context.clear': this.onContextClear(event, out); break; + case 'hook.result': this.onHookResult(event, out); break; + case 'skill.activated': this.onSkillActivated(event, out); break; + case 'plugin_command.activated': this.onPluginCommandActivated(event, out); break; + case 'shell.output': this.onShellOutput(event, out); break; + case 'agent.status.updated': this.onAgentStatusUpdated(event, out); break; + case 'plan.revision': this.onPlanRevision(event, out); break; + case 'goal.updated': this.onGoalUpdated(event, out); break; + case 'cron.fired': this.onCronFired(event, out); break; + case 'subagent.completed': this.onSubagentCompleted(event, out); break; + default: break; + } + return out; + } + + flushOpenTexts(time: number | undefined): ServerMessage[] { + const out: ServerMessage[] = []; + this.closeOpenTexts(time, out); + return out; + } + + applyInteractionPending(record: InteractionPendingRecord): ServerMessage[] { + const out: ServerMessage[] = []; + this.closeOpenTexts(record.time, out); + const acc: InteractionAcc = { + id: record.id, + kind: record.kind, + toolCallId: record.toolCallId, + request: record.request, + }; + this.interactions.set(acc.id, acc); + out.push(this.interactionMessage(acc, 'pending', record.time)); + if (acc.toolCallId !== undefined) { + const tool = this.tools.get(acc.toolCallId); + if (tool) { + tool.approvalId = acc.id; + out.push(this.toolCallMessage(tool, record.time)); + } + } + return out; + } + + applyInteractionResolved(record: InteractionResolvedRecord): ServerMessage[] { + const acc = this.interactions.get(record.id); + if (!acc) return []; + acc.resolved = true; + return [this.interactionMessage(acc, record.state, record.time, record.response)]; + } + + get turnCount(): number { + return this.maxTurnId + 1; + } + + recoveryEntities(now: () => number): ServerMessage[] { + const out: ServerMessage[] = []; + const turn = this.latestTurn(); + if (turn && turn.state === 'running') { + out.push(this.turnMessage(turn, now())); + const step = this.currentStep; + if (step && step.state === 'running') { + out.push(this.stepMessage(step, now())); + if (this.openThinking) { + out.push({ ...this.textMessage(this.openThinking, 'streaming', now()), text: this.openThinking.text }); + } + if (this.openAssistant) { + out.push({ ...this.textMessage(this.openAssistant, 'streaming', now()), text: this.openAssistant.text }); + } + for (const acc of this.tools.values()) { + if (acc.stepId === step.stepId && acc.state === 'running') out.push(this.toolCallMessage(acc, now())); + } + } + } + for (const acc of this.interactions.values()) { + if (!acc.resolved) out.push(this.interactionMessage(acc, 'pending', now())); + } + for (const task of this.tasks.values()) { + if (task.message.state === 'running') out.push({ ...task.message, timestamp: iso(now()) }); + } + if (this.todoId !== undefined && this.lastTodoItems !== undefined) { + out.push(this.todoMessage(this.todoId, this.lastTodoItems, now(), this.todoUpdatedAt)); + } + for (const acc of this.prompts.values()) { + if (acc.status === 'running' && acc.queued) out.push(this.userMessage(acc, now())); + } + return out; + } + + private base(event: ProjectionEvent): { session_id: string; agent_id: string; timestamp: string } { + return { session_id: this.sessionId, agent_id: this.agentId, timestamp: iso(event.time) }; + } + + private protocolTurnId(engineTurnId: number): string { + return `${this.turnIdPrefix}${engineTurnId + 1}`; + } + + private onPromptSubmitted(event: ProjectionEvent, out: ServerMessage[]): void { + const promptId = event.promptId as string; + const status = event.status as 'running' | 'queued'; + const steerHint = event.steer === true; + const content = event.content; + const origin = toUserOrigin(event.origin); + const text = textFromContent(content); + const createdAt = (event.createdAt as string) ?? iso(event.time); + if (steerHint) { + this.prompts.set(promptId, { + promptId, + messageId: '', + turnId: '', + text, + createdAt, + status: 'running', + queued: true, + steerHeld: true, + emitted: false, + origin, + }); + return; + } + const queued = status === 'queued'; + if (queued) this.queue.push(promptId); + const predictedEngineTurn = (event.turnId as number | undefined) ?? this.maxTurnId + this.queue.length + (queued ? 0 : 1); + const turnId = this.protocolTurnId(predictedEngineTurn); + const seq = this.nextUserSeq(predictedEngineTurn); + const messageId = `${turnId}.u${seq}`; + const acc: PromptAcc = { + promptId, + messageId, + turnId, + text, + createdAt, + status: 'running', + queued, + steerHeld: false, + emitted: true, + origin, + attachmentIds: event.attachmentIds as string[] | undefined, + skillActivations: toSkillActivations(event.origin), + }; + this.prompts.set(promptId, acc); + out.push(this.userMessage(acc, event.time)); + } + + private onCronFired(event: ProjectionEvent, out: ServerMessage[]): void { + const promptId = + (event.promptId as string | undefined) ?? `cron_${(event.origin as { jobId?: string } | undefined)?.jobId ?? ''}`; + const turn = this.latestTurn(); + if (turn && turn.state === 'running') { + const seq = turn.userSeq++; + const acc: PromptAcc = { + promptId, + messageId: `${turn.turnId}.u${seq}`, + turnId: turn.turnId, + text: (event.prompt as string) ?? '', + createdAt: iso(event.time), + status: 'running', + queued: false, + steerHeld: false, + emitted: true, + origin: toUserOrigin(event.origin), + steeredAt: iso(event.time), + }; + this.prompts.set(promptId, acc); + turn.promptIds.unshift(promptId); + out.push(this.userMessage(acc, event.time)); + return; + } + this.onPromptSubmitted( + { + type: 'prompt.submitted', + time: event.time, + promptId, + status: 'running', + turnId: event.turnId, + content: [{ type: 'text', text: (event.prompt as string) ?? '' }], + createdAt: iso(event.time), + origin: event.origin, + }, + out, + ); + } + + private onTaskNotified(event: ProjectionEvent, out: ServerMessage[]): void { + const title = (event.title as string) ?? ''; + const body = (event.body as string) ?? ''; + const taskId = (event.sourceId as string) ?? ''; + const notification: TaskNotificationPayload = { + title, + body, + severity: event.severity as string | undefined, + type: event.notificationType as string | undefined, + source_kind: event.sourceKind as string | undefined, + source_id: taskId || undefined, + agent_id: event.sourceAgentId as string | undefined, + raw: event.raw as string | undefined, + }; + const text = `${title}\n${body}`.trim(); + const turn = this.latestTurn(); + if (turn && turn.state === 'running') { + const seq = turn.userSeq++; + const acc: PromptAcc = { + promptId: `task_${taskId}`, + messageId: `${turn.turnId}.u${seq}`, + turnId: turn.turnId, + text, + createdAt: iso(event.time), + status: 'completed', + queued: false, + steerHeld: false, + emitted: true, + origin: { kind: 'task', task_id: taskId }, + notification, + }; + this.prompts.set(acc.promptId, acc); + out.push(this.userMessage(acc, event.time)); + return; + } + const engineTurnId = this.maxTurnId + 1; + const turnId = this.protocolTurnId(engineTurnId); + const seq = this.nextUserSeq(engineTurnId); + const acc: PromptAcc = { + promptId: `task_${taskId}`, + messageId: `${turnId}.u${seq}`, + turnId, + text, + createdAt: iso(event.time), + status: 'completed', + queued: false, + steerHeld: false, + emitted: true, + origin: { kind: 'task', task_id: taskId }, + notification, + }; + this.prompts.set(acc.promptId, acc); + out.push(this.userMessage(acc, event.time)); + } + + private nextUserSeq(engineTurnId: number): number { + const turn = this.turns.get(engineTurnId); + if (turn) return turn.userSeq++; + let seq = 0; + for (const acc of this.prompts.values()) { + if (acc.turnId === this.protocolTurnId(engineTurnId)) seq += 1; + } + return seq; + } + + private onPromptSteered(event: ProjectionEvent, out: ServerMessage[]): void { + const promptIds = (event.promptIds as string[]) ?? []; + const steeredAt = (event.steeredAt as string) ?? iso(event.time); + const turn = this.latestTurn(); + if (!turn) return; + const contentText = textFromContent(event.content); + for (const promptId of promptIds) { + const held = this.prompts.get(promptId); + const qi = this.queue.indexOf(promptId); + if (qi >= 0) this.queue.splice(qi, 1); + const seq = turn.userSeq++; + const acc: PromptAcc = { + promptId, + messageId: `${turn.turnId}.u${seq}`, + turnId: turn.turnId, + text: held?.text ?? contentText, + createdAt: held?.createdAt ?? steeredAt, + status: 'running', + queued: false, + steerHeld: false, + emitted: true, + origin: held?.origin, + steeredAt, + }; + this.prompts.set(promptId, acc); + turn.promptIds.push(promptId); + out.push(this.userMessage(acc, event.time)); + } + } + + private onTurnSteer(event: ProjectionEvent, out: ServerMessage[]): void { + const origin = toUserOrigin(event.origin); + if (origin?.kind !== 'skill' || origin.trigger !== 'user-slash') return; + const text = textFromContent(event.input); + const steeredAt = iso(event.time); + const activationId = (event.origin as { activationId?: string } | undefined)?.activationId; + const turn = this.latestTurn(); + if (turn && turn.state === 'running') { + const seq = turn.userSeq++; + const acc: PromptAcc = { + promptId: `skill_${activationId ?? `${turn.turnId}.${seq}`}`, + messageId: `${turn.turnId}.u${seq}`, + turnId: turn.turnId, + text, + createdAt: steeredAt, + status: 'running', + queued: false, + steerHeld: false, + emitted: true, + origin, + steeredAt, + }; + this.prompts.set(acc.promptId, acc); + out.push(this.userMessage(acc, event.time)); + return; + } + const engineTurnId = this.maxTurnId + 1; + const turnId = this.protocolTurnId(engineTurnId); + const seq = this.nextUserSeq(engineTurnId); + const acc: PromptAcc = { + promptId: `skill_${activationId ?? turnId}`, + messageId: `${turnId}.u${seq}`, + turnId, + text, + createdAt: steeredAt, + status: 'running', + queued: false, + steerHeld: false, + emitted: true, + origin, + steeredAt, + }; + this.prompts.set(acc.promptId, acc); + out.push(this.userMessage(acc, event.time)); + } + + private onPromptStarted(event: ProjectionEvent, out: ServerMessage[]): void { + const promptId = event.promptId as string; + const acc = this.prompts.get(promptId); + if (!acc || !acc.emitted) return; + const qi = this.queue.indexOf(promptId); + if (qi >= 0) this.queue.splice(qi, 1); + if (acc.queued) out.push(this.userMessage(acc, event.time)); + acc.queued = false; + } + + private onPromptCompleted(event: ProjectionEvent, out: ServerMessage[]): void { + const acc = this.prompts.get(event.promptId as string); + if (!acc || acc.status === 'completed') return; + acc.status = 'completed'; + if (!acc.emitted) this.assignHeld(acc, this.maxTurnId + 1); + out.push(this.userMessage(acc, event.time, (event.finishedAt as string) ?? iso(event.time))); + } + + private onPromptAborted(event: ProjectionEvent, out: ServerMessage[]): void { + const acc = this.prompts.get(event.promptId as string); + if (!acc) return; + const qi = this.queue.indexOf(acc.promptId); + if (qi >= 0) this.queue.splice(qi, 1); + if (acc.status !== 'completed') { + acc.status = 'completed'; + if (!acc.emitted) this.assignHeld(acc, this.maxTurnId + 1); + out.push(this.userMessage(acc, event.time, (event.abortedAt as string) ?? iso(event.time))); + } + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'interruption', + payload: { reason: 'aborted', turn_id: acc.turnId }, + }); + } + + private assignHeld(acc: PromptAcc, engineTurnId: number): void { + const turnId = this.protocolTurnId(engineTurnId); + const seq = this.nextUserSeq(engineTurnId); + acc.messageId = `${turnId}.u${seq}`; + acc.turnId = turnId; + acc.emitted = true; + } + + private onTurnStarted(event: ProjectionEvent, out: ServerMessage[]): void { + const engineTurnId = event.turnId as number; + this.maxTurnId = Math.max(this.maxTurnId, engineTurnId); + const origin = toTurnOrigin(event.origin); + const turn: TurnAcc = { + turnId: this.protocolTurnId(engineTurnId), + engineTurnId, + origin, + state: 'running', + startedAt: iso(event.time), + userSeq: 0, + promptIds: [], + usageInput: 0, + usageOutput: 0, + }; + let maxSeq = 0; + for (const acc of this.prompts.values()) { + if (acc.turnId === turn.turnId) maxSeq += 1; + } + turn.userSeq = maxSeq; + const promptId = event.promptId as string | undefined; + let heldAcc: PromptAcc | undefined; + if (promptId) { + const acc = this.prompts.get(promptId); + if (acc && !acc.emitted) { + this.assignHeld(acc, engineTurnId); + heldAcc = acc; + } + turn.userMessageId = acc?.messageId ?? promptId; + turn.promptIds.push(promptId); + turn.attachmentIds = acc?.attachmentIds; + } + this.turns.set(engineTurnId, turn); + if (maxSeq === 0 && heldAcc === undefined) { + const userOrigin = toUserOrigin(event.origin); + if (userOrigin !== undefined) { + const seq = turn.userSeq++; + const openingAcc: PromptAcc = { + promptId: `turn_${turn.turnId}`, + messageId: `${turn.turnId}.u${seq}`, + turnId: turn.turnId, + text: (event.prompt as string) ?? '', + createdAt: iso(event.time), + status: 'running', + queued: false, + steerHeld: false, + emitted: true, + origin: userOrigin, + skillActivations: toSkillActivations(event.origin), + }; + this.prompts.set(openingAcc.promptId, openingAcc); + turn.userMessageId = openingAcc.messageId; + heldAcc = openingAcc; + } + } + out.push(this.turnMessage(turn, event.time)); + if (heldAcc) out.push(this.userMessage(heldAcc, event.time)); + } + + private onTurnEnded(event: ProjectionEvent, out: ServerMessage[]): void { + const engineTurnId = event.turnId as number; + const turn = this.turns.get(engineTurnId); + if (!turn || turn.state === 'completed') return; + turn.state = 'completed'; + this.closeOpenTexts(event.time, out); + const usage = + turn.usageInput > 0 || turn.usageOutput > 0 + ? { + input_tokens: turn.usageInput, + output_tokens: turn.usageOutput, + } + : undefined; + const msg: TurnMessage = { + type: 'turn', + ...this.base(event), + turn_id: turn.turnId, + ordinal: turn.engineTurnId, + state: 'completed', + origin: turn.origin, + user_message_id: turn.userMessageId, + attachment_ids: turn.attachmentIds, + started_at: turn.startedAt, + ended_at: iso((event.endedAt as number | undefined) ?? event.time), + usage, + duration_ms: event.durationMs as number | undefined, + }; + out.push(msg); + for (const promptId of turn.promptIds) { + const acc = this.prompts.get(promptId); + if (acc && acc.status !== 'completed') { + acc.status = 'completed'; + out.push(this.userMessage(acc, event.time, iso((event.endedAt as number | undefined) ?? event.time))); + } + } + } + + private onStepStarted(event: ProjectionEvent, out: ServerMessage[]): void { + const engineTurnId = event.turnId as number; + const ordinal = (event.step as number) - 1; + const turnId = this.protocolTurnId(engineTurnId); + this.closeOpenTexts(event.time, out); + const step: StepAcc = { + stepId: `${turnId}.${ordinal}`, + turnId, + engineTurnId, + ordinal, + state: 'running', + startedAt: iso(event.time), + textSeq: { a: 0, h: 0 }, + }; + this.steps.set(step.stepId, step); + this.currentStep = step; + out.push(this.stepMessage(step, event.time)); + } + + private onStepCompleted(event: ProjectionEvent, out: ServerMessage[]): void { + const step = this.currentStep; + if (!step || step.state !== 'running') return; + this.closeOpenTexts(event.time, out); + step.state = 'completed'; + step.endedAt = (event.endedAt as number | undefined) ?? event.time; + step.usage = toStepUsage(event.usage); + step.finishReason = event.finishReason as string | undefined; + step.retry = undefined; + const timing = { + llm_first_token_ms: event.llmFirstTokenLatencyMs as number | undefined, + llm_stream_duration_ms: event.llmStreamDurationMs as number | undefined, + }; + step.timing = timing.llm_first_token_ms !== undefined || timing.llm_stream_duration_ms !== undefined ? timing : undefined; + const turn = this.turns.get(step.engineTurnId); + if (turn && step.usage) { + turn.usageInput += step.usage.input_other; + turn.usageOutput += step.usage.output; + } + out.push(this.stepMessage(step, event.time)); + this.currentStep = undefined; + } + + private onStepInterrupted(event: ProjectionEvent, out: ServerMessage[]): void { + const step = this.currentStep; + if (!step || step.state !== 'running') return; + this.closeOpenTexts(event.time, out); + step.state = 'interrupted'; + step.endedAt = (event.endedAt as number | undefined) ?? event.time; + step.endReason = event.reason as string | undefined; + step.endMessage = event.message as string | undefined; + out.push(this.stepMessage(step, event.time)); + this.currentStep = undefined; + } + + private onStepRetrying(event: ProjectionEvent, out: ServerMessage[]): void { + const step = this.currentStep; + if (!step) return; + step.retry = { + failed_attempt: event.failedAttempt as number, + next_attempt: event.nextAttempt as number, + max_attempts: event.maxAttempts as number, + delay_ms: event.delayMs as number, + error_name: event.errorName as string, + error_message: event.errorMessage as string, + status_code: event.statusCode as number | undefined, + }; + this.resetOpenTexts(); + out.push(this.stepMessage(step, event.time)); + } + + private resetOpenTexts(): void { + if (this.openAssistant) { + this.openAssistant.text = ''; + this.openAssistant.announced = false; + } + if (this.openThinking) { + this.openThinking.text = ''; + this.openThinking.announced = false; + } + } + + private onTextDelta(event: ProjectionEvent, kind: 'assistant' | 'thinking', out: ServerMessage[]): void { + const delta = (event.delta as string) ?? ''; + const step = this.currentStep; + if (!step) return; + let acc = kind === 'assistant' ? this.openAssistant : this.openThinking; + if (!acc && delta.length === 0) return; + if (!acc || acc.stepKey !== step.stepId) { + this.closeOpenTexts(event.time, out); + const seq = kind === 'assistant' ? step.textSeq.a++ : step.textSeq.h++; + acc = { + kind, + messageId: `${step.stepId}.${kind === 'assistant' ? 'a' : 'h'}${seq}`, + stepKey: step.stepId, + turnId: step.turnId, + stepId: step.stepId, + text: '', + announced: false, + }; + if (kind === 'assistant') this.openAssistant = acc; + else this.openThinking = acc; + } + if (!acc.announced) { + out.push(this.textMessage(acc, 'streaming', event.time)); + acc.announced = true; + } + acc.text += delta; + if (delta.length === 0) return; + if (kind === 'assistant') { + const msg: AssistantDeltaMessage = { type: 'assistant.delta', ...this.base(event), message_id: acc.messageId, text: delta }; + out.push(msg); + } else { + const msg: ThinkingDeltaMessage = { type: 'thinking.delta', ...this.base(event), message_id: acc.messageId, text: delta }; + out.push(msg); + } + } + + private closeOpenTexts(time: number | undefined, out: ServerMessage[]): void { + if (this.openAssistant) { + out.push(this.textMessage(this.openAssistant, 'completed', time)); + this.openAssistant = undefined; + } + if (this.openThinking) { + out.push(this.textMessage(this.openThinking, 'completed', time)); + this.openThinking = undefined; + } + } + + private onToolCallDelta(event: ProjectionEvent, out: ServerMessage[]): void { + const toolCallId = event.toolCallId as string; + const part = (event.argumentsPart as string) ?? ''; + this.closeOpenTexts(event.time, out); + let acc = this.tools.get(toolCallId); + if (!acc) { + const step = this.currentStep; + acc = { + toolCallId, + turnId: step?.turnId ?? '', + stepId: step?.stepId ?? '', + name: (event.name as string) ?? '', + state: 'running', + inputText: '', + opened: false, + }; + this.tools.set(toolCallId, acc); + } + acc.inputText = (acc.inputText ?? '') + part; + out.push({ + type: 'tool_call.delta', + ...this.base(event), + tool_call_id: toolCallId, + input_text: part, + }); + } + + private onToolCallStarted(event: ProjectionEvent, out: ServerMessage[]): void { + const toolCallId = event.toolCallId as string; + const step = this.currentStep; + this.closeOpenTexts(event.time, out); + const prev = this.tools.get(toolCallId); + const normalized = normalizeTodoToolCall((event.name as string) ?? prev?.name ?? '', event.args); + const acc: ToolAcc = { + toolCallId, + turnId: step?.turnId ?? prev?.turnId ?? '', + stepId: step?.stepId ?? prev?.stepId ?? '', + name: normalized.name, + state: 'running', + input: normalized.input, + inputText: prev?.inputText, + display: event.display, + agentRefs: (event.agentRefs as ToolCallAgentRef[] | undefined) ?? prev?.agentRefs, + opened: true, + }; + if (acc.name === 'TodoWrite' && todoItemsFromInput(acc.input) !== undefined) { + this.todoId = this.todoId ?? this.nextTodoId(); + acc.todoId = this.todoId; + } + this.tools.set(toolCallId, acc); + out.push(this.toolCallMessage(acc, event.time)); + } + + private onToolProgress(event: ProjectionEvent, out: ServerMessage[]): void { + const toolCallId = event.toolCallId as string; + const progress = toToolProgress(event.update); + const acc = this.tools.get(toolCallId); + if (acc) acc.progress = progress; + out.push({ type: 'tool.progress', ...this.base(event), tool_call_id: toolCallId, progress }); + } + + private onToolResult(event: ProjectionEvent, out: ServerMessage[]): void { + const toolCallId = event.toolCallId as string; + const acc = this.tools.get(toolCallId); + if (!acc) return; + const isError = event.isError === true; + acc.state = isError ? 'error' : 'done'; + acc.progress = undefined; + if (isError) { + acc.error = typeof event.output === 'string' ? event.output : JSON.stringify(event.output); + } else { + acc.output = event.output; + } + out.push(this.toolCallMessage(acc, event.time)); + if (!isError && acc.todoId !== undefined) this.todoUpdatedAt = event.time; + if (!isError && acc.name === 'ExitPlanMode') { + this.planExitApproved = (event.output as { approved?: boolean } | undefined)?.approved; + } + } + + private onToolsUpdateStore(event: ProjectionEvent, out: ServerMessage[]): void { + if (event.key !== 'todo') return; + const items = todoItemsFromList(event.value); + if (!items) return; + this.todoId = this.todoId ?? this.nextTodoId(); + this.lastTodoItems = items; + out.push(this.todoMessage(this.todoId, items, event.time, this.todoUpdatedAt)); + } + + private onApprovalRequested(event: ProjectionEvent, out: ServerMessage[]): void { + out.push( + ...this.applyInteractionPending({ + id: event.id as string, + kind: 'approval', + toolCallId: event.toolCallId as string, + request: { tool_name: event.toolName as string, input: event.toolInput, reason: event.action as string, display: event.display }, + time: event.time, + }), + ); + } + + private onApprovalResolved(event: ProjectionEvent, out: ServerMessage[]): void { + const decision = event.decision as string; + const state = decision === 'approved' || decision === 'rejected' || decision === 'cancelled' ? decision : 'cancelled'; + out.push( + ...this.applyInteractionResolved({ + id: event.id as string, + state, + response: { decision: state, feedback: event.feedback as string | undefined }, + time: event.time, + }), + ); + } + + private onCompactionCompleted(event: ProjectionEvent, out: ServerMessage[]): void { + const result = event.result as { tokensBefore?: number; tokensAfter?: number } | undefined; + if (!result) return; + const turn = this.latestTurn(); + const through = turn && turn.engineTurnId > 0 ? this.protocolTurnId(turn.engineTurnId - 1) : undefined; + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'compaction', + payload: { + before_tokens: result.tokensBefore as number, + after_tokens: result.tokensAfter as number, + summarized_through_turn: through, + }, + }); + } + + private onContextUndone(event: ProjectionEvent, out: ServerMessage[]): void { + const fromTurnId = event.fromTurnId as number | undefined; + if (fromTurnId === undefined) return; + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'undo', + payload: { undo_turn_id: this.protocolTurnId(fromTurnId) }, + }); + } + + private onAgentStatusUpdated(event: ProjectionEvent, out: ServerMessage[]): void { + const planMode = event.planMode as boolean | undefined; + if (planMode === true) { + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'plan.enter', + payload: { mode: 'plan' }, + }); + } else if (planMode === false) { + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'plan.exit', + payload: { approved: this.planExitApproved ?? false, version: this.planVersion, key: this.planKey }, + }); + } + const swarmMode = event.swarmMode as boolean | undefined; + if (swarmMode === true) { + out.push({ type: 'system', ...this.base(event), system_id: this.nextSystemId(), subtype: 'swarm.enter' }); + } else if (swarmMode === false) { + out.push({ type: 'system', ...this.base(event), system_id: this.nextSystemId(), subtype: 'swarm.exit' }); + } + } + + private onContextClear(event: ProjectionEvent, out: ServerMessage[]): void { + out.push({ type: 'system', ...this.base(event), system_id: this.nextSystemId(), subtype: 'clear' }); + } + + private onHookResult(event: ProjectionEvent, out: ServerMessage[]): void { + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'hook', + payload: { event: event.hookEvent as string | undefined, content: event.content, blocked: event.blocked === true ? true : undefined }, + }); + } + + private onSkillActivated(_event: ProjectionEvent, _out: ServerMessage[]): void {} + + private onPluginCommandActivated(event: ProjectionEvent, out: ServerMessage[]): void { + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'notice', + payload: { message: event.commandName as string | undefined }, + }); + } + + private onPlanRevision(event: ProjectionEvent, out: ServerMessage[]): void { + this.closeOpenTexts(event.time, out); + this.planVersion = event.version as number | undefined; + this.planKey = event.key as string | undefined; + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'plan.revision', + payload: { + version: event.version as number, + key: event.key as string | undefined, + summary: event.summary as string | undefined, + }, + }); + } + + private lastGoalStatus?: string; + + private onGoalUpdated(event: ProjectionEvent, out: ServerMessage[]): void { + const snapshot = event.snapshot as { status?: string; objective?: string } | null | undefined; + const status = snapshot?.status; + if (status === this.lastGoalStatus) return; + this.lastGoalStatus = status; + if (!snapshot) return; + out.push({ + type: 'system', + ...this.base(event), + system_id: this.nextSystemId(), + subtype: 'goal', + payload: { status: snapshot.status as string, objective: snapshot.objective }, + }); + } + + private onSubagentCompleted(event: ProjectionEvent, out: ServerMessage[]): void { + const step = this.currentStep; + if (!step) return; + this.closeOpenTexts(event.time, out); + const seq = step.textSeq.a++; + const acc: TextAcc = { + kind: 'assistant', + messageId: `${step.stepId}.a${seq}`, + stepKey: step.stepId, + turnId: step.turnId, + stepId: step.stepId, + text: (event.resultSummary as string) ?? '', + announced: true, + }; + out.push(this.textMessage(acc, 'completed', event.time)); + } + + private onShellOutput(event: ProjectionEvent, out: ServerMessage[]): void { + const taskId = event.taskId as string | undefined; + if (taskId === undefined) return; + const prev = this.tasks.get(taskId); + if (!prev) return; + const update = event.update as { text?: string } | undefined; + const msg: TaskMessage = { + ...prev.message, + timestamp: iso(event.time), + output_tail: update?.text ?? prev.message.output_tail, + }; + this.tasks.set(taskId, { taskId, message: msg }); + out.push(msg); + } + + private interactionMessage( + acc: InteractionAcc, + state: InteractionMessage['state'], + time: number | undefined, + response?: ApprovalResponsePayload | QuestionResponsePayload, + ): InteractionMessage { + const base = { + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + interaction_id: acc.id, + state, + tool_call_id: acc.toolCallId, + }; + if (acc.kind === 'approval') { + return { + type: 'interaction', + ...base, + kind: 'approval', + request: acc.request as ApprovalRequest, + response: response as ApprovalResponsePayload | undefined, + }; + } + return { + type: 'interaction', + ...base, + kind: 'question', + request: acc.request as QuestionRequest, + response: response as QuestionResponsePayload | undefined, + }; + } + + private onTaskStarted(event: ProjectionEvent, out: ServerMessage[]): void { + const info = event.info as TaskInfoPayload | undefined; + if (!info) return; + const taskId = info.taskId as string; + const msg: TaskMessage = { + type: 'task', + ...this.base(event), + task_id: taskId, + kind: toTaskKind(info.kind as string | undefined), + state: (info.status as TaskMessage['state']) ?? 'running', + detached: info.detached === true, + description: info.description as string | undefined, + output_tail: (info.outputTail as string) ?? '', + started_at: (info.startedAt as string) ?? iso(event.time), + model: info.model as string | undefined, + thinking_effort: info.thinkingEffort as string | undefined, + child_agent_id: info.childAgentId as string | undefined, + }; + this.tasks.set(taskId, { taskId, message: msg }); + out.push(msg); + } + + private onTaskTerminated(event: ProjectionEvent, out: ServerMessage[]): void { + const info = event.info as TaskInfoPayload | undefined; + if (!info) return; + const taskId = info.taskId as string; + const prev = this.tasks.get(taskId)?.message; + const msg: TaskMessage = { + type: 'task', + ...this.base(event), + task_id: taskId, + kind: toTaskKind(info.kind as string | undefined), + state: (info.status as TaskMessage['state']) ?? 'completed', + detached: prev?.detached ?? info.detached === true, + description: (info.description as string | undefined) ?? prev?.description, + output_tail: (event.outputTail as string) ?? prev?.output_tail ?? '', + started_at: prev?.started_at ?? (info.startedAt as string | undefined), + ended_at: (info.endedAt as string) ?? iso(event.time), + result_summary: info.resultSummary as string | undefined, + error: info.error as string | undefined, + state_reason: info.stopReason as string | undefined, + usage: toStepUsage(info.usage), + model: prev?.model, + thinking_effort: prev?.thinking_effort, + child_agent_id: (info.childAgentId as string | undefined) ?? prev?.child_agent_id, + }; + this.tasks.set(taskId, { taskId, message: msg }); + out.push(msg); + } + + private latestTurn(): TurnAcc | undefined { + return this.turns.get(this.maxTurnId); + } + + private userMessage(acc: PromptAcc, time: number | undefined, finishedAt?: string): UserMessage { + return { + type: 'user', + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + message_id: acc.messageId, + turn_id: acc.turnId, + text: acc.text, + status: acc.status, + created_at: acc.createdAt, + finished_at: acc.status === 'completed' ? (finishedAt ?? iso(time)) : undefined, + steered_at: acc.steeredAt, + origin: acc.origin, + attachment_ids: acc.attachmentIds, + notification: acc.notification, + skill_activations: acc.skillActivations, + }; + } + + private turnMessage(turn: TurnAcc, time: number | undefined): TurnMessage { + return { + type: 'turn', + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + turn_id: turn.turnId, + ordinal: turn.engineTurnId, + state: turn.state, + origin: turn.origin, + user_message_id: turn.userMessageId, + attachment_ids: turn.attachmentIds, + started_at: turn.startedAt, + }; + } + + private stepMessage(step: StepAcc, time: number | undefined): StepMessage { + return { + type: 'step', + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + step_id: step.stepId, + turn_id: step.turnId, + ordinal: step.ordinal, + state: step.state, + started_at: step.startedAt, + ended_at: step.state === 'running' ? undefined : iso(step.endedAt ?? time), + usage: step.usage, + finish_reason: step.finishReason, + timing: step.timing, + retry: step.retry, + end_reason: step.endReason, + end_message: step.endMessage, + }; + } + + private textMessage(acc: TextAcc, status: 'streaming' | 'completed', time: number | undefined): AssistantMessage | ThinkingMessage { + return { + type: acc.kind, + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + message_id: acc.messageId, + turn_id: acc.turnId, + step_id: acc.stepId, + status, + text: status === 'streaming' ? '' : acc.text, + }; + } + + private toolCallMessage(acc: ToolAcc, time: number | undefined): ToolCallMessage { + return { + type: 'tool_call', + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + tool_call_id: acc.toolCallId, + turn_id: acc.turnId, + step_id: acc.stepId, + name: acc.name, + state: acc.state, + input: acc.input, + output: acc.output, + display: acc.display, + error: acc.error, + progress: acc.progress, + approval_id: acc.approvalId, + task_id: acc.taskId, + todo_id: acc.todoId, + agent_refs: acc.agentRefs, + }; + } + + private todoMessage(todoId: string, items: TodoItem[], time: number | undefined, updatedAt?: number): TodoMessage { + return { + type: 'todo', + session_id: this.sessionId, + agent_id: this.agentId, + timestamp: iso(time), + todo_id: todoId, + items, + updated_at: iso(updatedAt ?? time), + }; + } + + private nextTodoId(): string { + this.todoSeq += 1; + return `td_${String(this.todoSeq).padStart(2, '0')}`; + } + + protected nextSystemId(): string { + this.systemSeq += 1; + return `m_${String(this.systemSeq).padStart(2, '0')}`; + } +} + +export function toTaskKind(kind: string | undefined): TaskMessage['kind'] { + switch (kind) { + case 'shell': return 'shell'; + case 'agent': return 'subagent'; + case 'tool': return 'tool'; + default: return 'other'; + } +} + +export function normalizeTodoToolCall(name: string, input: unknown): { name: string; input: unknown } { + if (name !== 'TodoList') return { name, input }; + const todos = (input as { todos?: unknown } | undefined)?.todos; + return { name: 'TodoWrite', input: todos === undefined ? input : { items: todos } }; +} + +export function todoItemsFromInput(input: unknown): TodoItem[] | undefined { + const i = input as { todos?: unknown; items?: unknown } | undefined; + return todoItemsFromList(i?.todos ?? i?.items); +} + +export function todoItemsFromList(list: unknown): TodoItem[] | undefined { + if (!Array.isArray(list)) return undefined; + const items: TodoItem[] = []; + for (const entry of list) { + const e = entry as { title?: string; content?: string; status?: string }; + const title = e.title ?? e.content; + if (typeof title !== 'string') return undefined; + items.push({ + title, + status: e.status === 'in_progress' || e.status === 'done' ? e.status : 'pending', + }); + } + return items; +} diff --git a/packages/kap-server/src/services/v2Projection/binder.ts b/packages/kap-server/src/services/v2Projection/binder.ts new file mode 100644 index 00000000000..28bbe92b2a7 --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/binder.ts @@ -0,0 +1,361 @@ +import type { + AgentContext, + Scope, + SessionActivityState, +} from '@moonshot-ai/agent-core-v2'; +import { + IAgentInteractionService, + IAgentLifecycleService, + IAgentPermissionModeService, + IEventBus, + ISessionActivityView, + getLiveSessionById, + onSessionInteractionDidChangePending, + onSessionInteractionDidResolve, +} from '@moonshot-ai/agent-core-v2'; + +import { serverMessageSchema, type ServerMessage } from '../../protocol/v2/messages/index'; +import type { ProjectionEvent } from './agentProjector'; +import { toWireInteractionRequest, toWireInteractionResponse } from './interactionWire'; +import { SessionV2Projector } from './sessionProjector'; +import type { ComposerTurnFact, SessionFactsPatch } from './sessionStateComposer'; + +export interface V2Disposable { + dispose(): void; +} + +export type V2BusEvent = ProjectionEvent & { type: string }; + +export interface V2AgentSource { + readonly agentId: string; + readonly bus: { + subscribe(handler: (event: V2BusEvent) => void): V2Disposable; + }; + permissionMode?(): 'manual' | 'yolo' | 'auto' | undefined; +} + +export interface V2PendingInteraction { + readonly id: string; + readonly kind: 'approval' | 'question'; + readonly toolCallId?: string; + readonly request: unknown; +} + +export interface V2InteractionSource { + listPending(agentId: string): readonly V2PendingInteraction[]; + onDidChangePending(handler: (agentId: string) => void): V2Disposable; + onDidResolve(handler: (event: { agentId: string; id: string; response: unknown }) => void): V2Disposable; +} + +export interface V2SessionSource { + readonly sessionId: string; + agents(): readonly V2AgentSource[]; + agentFor(agentId: string): V2AgentSource | undefined; + onAgentCreated?(handler: (agentId: string) => void): V2Disposable; + readonly activity?: { + state(): SessionActivityState; + onDidChange(handler: (state: SessionActivityState, time?: number) => void): V2Disposable; + }; + readonly interactions?: V2InteractionSource; +} + +interface StatusFactEvent { + model?: string; + thinkingEffort?: string; + contextTokens?: number; + maxContextTokens?: number; + usage?: { byModel?: Record; total?: unknown; currentTurn?: unknown }; +} + +function factsPatchForEvent(event: V2BusEvent): SessionFactsPatch | undefined { + switch (event.type) { + case 'agent.status.updated': { + const status: StatusFactEvent = {}; + const model = event.model as string | undefined; + const thinkingEffort = event.thinkingEffort as string | undefined; + const contextTokens = event.contextTokens as number | undefined; + const maxContextTokens = event.maxContextTokens as number | undefined; + const usage = event.usage as StatusFactEvent['usage']; + if (model !== undefined) status.model = model; + if (thinkingEffort !== undefined) status.thinkingEffort = thinkingEffort; + if (contextTokens !== undefined) status.contextTokens = contextTokens; + if (maxContextTokens !== undefined) status.maxContextTokens = maxContextTokens; + if (usage !== undefined) status.usage = usage; + return Object.keys(status).length > 0 ? { status } : undefined; + } + case 'agent.activity.updated': { + const lifecycle = event.lifecycle as 'ready' | 'disposed' | undefined; + const turn = event.turn as ComposerTurnFact | undefined; + if (lifecycle === undefined) return undefined; + if (turn !== undefined && typeof turn.step === 'number') { + return { agentActivity: { lifecycle, turn: { ...turn, step: Math.max(0, turn.step - 1) } } }; + } + return { agentActivity: { lifecycle, turn } }; + } + case 'goal.updated': { + const snapshot = event.snapshot as + | { objective?: string; status?: 'active' | 'paused' | 'blocked' | 'complete'; completionCriterion?: string; turnsUsed?: number; budget?: { turnBudget?: number | null } } + | null + | undefined; + if (!snapshot || snapshot.objective === undefined || snapshot.status === undefined) { + return { goal: null }; + } + return { + goal: { + objective: snapshot.objective, + status: snapshot.status, + completionCriterion: snapshot.completionCriterion, + budgetUsed: snapshot.turnsUsed, + budgetLimit: snapshot.budget?.turnBudget ?? undefined, + }, + }; + } + case 'profile.bind': { + const model = event.model as string | undefined; + return model === undefined ? undefined : { status: { model } }; + } + case 'permission.set_mode': { + const mode = event.mode as 'manual' | 'yolo' | 'auto' | undefined; + return mode === undefined ? undefined : { permission: mode }; + } + default: + return undefined; + } +} + +export class SessionV2Binder { + private readonly bindings = new Map(); + constructor(private readonly clock: () => number = Date.now) {} + + peek(sessionId: string): SessionV2Binding | undefined { + return this.bindings.get(sessionId); + } + + attach(source: V2SessionSource): SessionV2Binding { + let binding = this.bindings.get(source.sessionId); + if (!binding) { + binding = new SessionV2Binding(source, this.clock); + this.bindings.set(source.sessionId, binding); + } + return binding; + } + + detach(sessionId: string): void { + const binding = this.bindings.get(sessionId); + if (!binding) return; + this.bindings.delete(sessionId); + binding.dispose(); + } +} + +export class SessionV2Binding { + readonly projector: SessionV2Projector; + private readonly agentBindings = new Map(); + private readonly sessionListeners = new Set<(msgs: ServerMessage[]) => void>(); + private readonly seenInteractions = new Map(); + private readonly disposables: V2Disposable[] = []; + + constructor( + private readonly source: V2SessionSource, + private readonly clock: () => number, + ) { + this.projector = new SessionV2Projector(source.sessionId); + for (const agent of source.agents()) this.watchAgent(agent); + const created = source.onAgentCreated?.((agentId) => { + const agent = source.agentFor(agentId); + if (agent) this.watchAgent(agent); + }); + if (created) this.disposables.push(created); + const activity = source.activity; + if (activity) { + this.projector.composer.apply({ activity: activity.state() }); + this.disposables.push( + activity.onDidChange((state, time) => this.emitFacts({ activity: state }, time ?? this.clock())), + ); + } + const interactions = source.interactions; + if (interactions) { + this.disposables.push(interactions.onDidChangePending((agentId) => this.syncPendingInteractions(agentId))); + this.disposables.push( + interactions.onDidResolve((event) => this.applyInteractionResolution(event.agentId, event.id, event.response)), + ); + } + } + + private watchAgent(agent: V2AgentSource): void { + const permission = agent.permissionMode?.(); + if (permission !== undefined) this.projector.composer.apply({ permission }); + this.disposables.push(agent.bus.subscribe((event) => this.onAgentEvent(agent.agentId, event))); + } + + private factsFlushScheduled = false; + private factsFlushTime = 0; + private pendingFacts: SessionFactsPatch[] = []; + + private onAgentEvent(agentId: string, event: V2BusEvent): void { + const msgs = this.projector.applyAgentEvent(agentId, event); + if (msgs.length > 0) this.agentFor(agentId).emit(msgs); + const patch = factsPatchForEvent(event); + if (patch) this.emitFacts(patch, event.time ?? this.clock()); + } + + private syncPendingInteractions(agentId: string): void { + const source = this.source.interactions; + if (!source) return; + for (const pending of source.listPending(agentId)) { + if (pending.kind !== 'question') continue; + if (this.seenInteractions.has(pending.id)) continue; + this.seenInteractions.set(pending.id, agentId); + const msgs = this.projector.agentFor(agentId).applyInteractionPending({ + id: pending.id, + kind: 'question', + toolCallId: pending.toolCallId, + request: toWireInteractionRequest('question', pending.request) as never, + time: this.clock(), + }); + if (msgs.length > 0) this.agentFor(agentId).emit(msgs); + } + } + + private applyInteractionResolution(agentId: string, id: string, response: unknown): void { + const owner = this.seenInteractions.get(id) ?? agentId; + if (!this.seenInteractions.has(id)) return; + const dismissed = response === null || response === undefined; + const msgs = this.projector.agentFor(owner).applyInteractionResolved({ + id, + state: dismissed ? 'dismissed' : 'answered', + response: toWireInteractionResponse('question', response) as never, + time: this.clock(), + }); + if (msgs.length > 0) this.agentFor(owner).emit(msgs); + } + + private emitFacts(patch: SessionFactsPatch, time: number): void { + this.pendingFacts.push(patch); + this.factsFlushTime = time; + if (this.factsFlushScheduled) return; + this.factsFlushScheduled = true; + queueMicrotask(() => { + this.factsFlushScheduled = false; + const pending = this.pendingFacts.splice(0); + const last = pending.pop(); + if (last === undefined) return; + for (const earlier of pending) this.projector.composer.apply(earlier); + const msgs = this.projector.applyFacts(last, this.factsFlushTime, false); + if (msgs.length === 0) return; + for (const listener of this.sessionListeners) listener(msgs); + }); + } + + agentFor(agentId: string): AgentV2Binding { + let binding = this.agentBindings.get(agentId); + if (!binding) { + binding = new AgentV2Binding(this, agentId); + this.agentBindings.set(agentId, binding); + } + return binding; + } + + onSessionMessages(listener: (msgs: ServerMessage[]) => void): V2Disposable { + this.sessionListeners.add(listener); + return { dispose: () => this.sessionListeners.delete(listener) }; + } + + emitAgentMessages(agentId: string, msgs: ServerMessage[]): void { + for (const listener of this.agentFor(agentId).listeners) listener(msgs); + } + + recoveryFor(agentId: string): ServerMessage[] { + const out = this.projector.agentFor(agentId).recoveryEntities(() => this.clock()); + const composer = this.projector.composer; + if (composer.hasFacts()) { + const state = composer.compose(this.clock(), (turnId, step) => `t${turnId + 1}.${step}`); + if (state) out.push(state); + } + return out.filter((msg) => serverMessageSchema.safeParse(msg).success); + } + + dispose(): void { + for (const disposable of this.disposables) disposable.dispose(); + } +} + +export class AgentV2Binding { + readonly listeners = new Set<(msgs: ServerMessage[]) => void>(); + constructor( + readonly session: SessionV2Binding, + readonly agentId: string, + ) {} + + onMessages(listener: (msgs: ServerMessage[]) => void): V2Disposable { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; + } + + emit(msgs: ServerMessage[]): void { + for (const listener of this.listeners) listener(msgs); + } +} + +export function liveSessionSourceFor(core: Scope, sessionId: string): V2SessionSource | undefined { + const session = getLiveSessionById(core.accessor, sessionId); + if (session === undefined) return undefined; + const lifecycle = session.accessor.get(IAgentLifecycleService); + const agentFor = (context: AgentContext): V2AgentSource => { + const handle = lifecycle.handleOf(context.agentId); + const bus = handle?.accessor.get(IEventBus); + return { + agentId: context.agentId, + bus: { + subscribe: (handler) => { + if (bus === undefined) return { dispose: () => {} }; + const subscription = bus.subscribe((event: unknown) => handler(event as V2BusEvent)); + return { dispose: () => subscription.dispose() }; + }, + }, + permissionMode: () => handle?.accessor.get(IAgentPermissionModeService)?.mode, + }; + }; + const interactions: V2InteractionSource = { + listPending: (agentId) => + (lifecycle.handleOf(agentId)?.accessor.get(IAgentInteractionService)?.listPending() ?? []).map( + (interaction) => ({ + id: interaction.id, + kind: interaction.kind === 'question' ? ('question' as const) : ('approval' as const), + request: interaction.payload, + }), + ), + onDidChangePending: (handler) => { + const store = onSessionInteractionDidChangePending(lifecycle, () => { + for (const context of lifecycle.list()) handler(context.agentId); + }); + return { dispose: () => store.dispose() }; + }, + onDidResolve: (handler) => { + const store = onSessionInteractionDidResolve(lifecycle, (event) => { + handler({ agentId: 'main', id: event.id, response: event.response }); + }); + return { dispose: () => store.dispose() }; + }, + }; + return { + sessionId, + agents: () => lifecycle.list().map((context) => agentFor(context)), + agentFor: (agentId) => { + const context = lifecycle.list().find((candidate) => candidate.agentId === agentId); + return context === undefined ? undefined : agentFor(context); + }, + onAgentCreated: (handler) => { + const subscription = lifecycle.onDidCreate((context) => handler(context.agentId)); + return { dispose: () => subscription.dispose() }; + }, + activity: session.accessor.get(ISessionActivityView) === undefined + ? undefined + : { + state: () => session.accessor.get(ISessionActivityView).state(), + onDidChange: (handler) => + session.accessor.get(ISessionActivityView).onDidChange((change) => handler(change.state)), + }, + interactions, + }; +} diff --git a/packages/kap-server/src/services/v2Projection/coldHistory.ts b/packages/kap-server/src/services/v2Projection/coldHistory.ts new file mode 100644 index 00000000000..32d54eab02b --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/coldHistory.ts @@ -0,0 +1,966 @@ +import type { + ServerMessage, + SkillActivation, + StepUsage, + TaskMessage, + TaskNotificationPayload, + ToolCallAgentRef, + TurnOrigin, + UserMessageOrigin, +} from '../../protocol/v2/messages/index'; +import { + normalizeTodoToolCall, + textFromContent, + toSkillActivations, + toStepUsage, + toTaskKind, + toTurnOrigin, + toUserOrigin, + todoItemsFromInput, +} from './agentProjector'; +import { toWireInteractionRequest, toWireInteractionResponse } from './interactionWire'; + +export interface ColdWireRecord { + type: string; + time?: number; + promptId?: unknown; + createdAt?: unknown; + origin?: unknown; + input?: unknown; + finishedAt?: unknown; + abortedAt?: unknown; + event?: unknown; + turnId?: unknown; + step?: unknown; + reason?: unknown; + message?: unknown; + usage?: unknown; + durationMs?: unknown; + id?: unknown; + kind?: unknown; + toolCallId?: unknown; + request?: unknown; + response?: unknown; + info?: unknown; + outputTail?: unknown; + fromTurnId?: unknown; + tokensBefore?: unknown; + tokensAfter?: unknown; + [key: string]: unknown; +} + +export interface ColdHistoryQuery { + beforeTurn?: string; + afterStep?: string; + pageSize?: number; +} + +export interface ColdInFlight { + turn_id: string; + step_id?: string; +} + +export interface ColdHistoryPage { + session_id: string; + items: ServerMessage[]; + has_more: boolean; + in_flight: ColdInFlight | null; +} + +const COVER_SEAL_OFFSET_MS = 5; +const DEFAULT_PAGE_SIZE = 50; + +interface PromptAcc { + acceptedTime?: number; + createdAt?: string; + finishedAt?: string; +} + +interface TextAcc { + kind: 'assistant' | 'thinking'; + seq: number; + text: string; + firstTime?: number; + lastTime?: number; +} + +interface ToolAcc { + toolCallId: string; + name: string; + input?: unknown; + callTime?: number; + resultTime?: number; + output?: unknown; + isError?: boolean; + approvalId?: string; + todoId?: string; + agentRefs?: ToolCallAgentRef[]; +} + +interface InteractionAcc { + id: string; + kind: 'approval' | 'question'; + toolCallId?: string; + request: unknown; + requestTime?: number; + response?: unknown; + resolvedTime?: number; +} + +interface SystemAcc { + subtype: 'interruption' | 'undo' | 'goal'; + payload: Record; + time?: number; + recordIndex: number; +} + +interface StepAcc { + turn: TurnAcc; + uuid?: string; + ordinal: number; + beginTime?: number; + endTime?: number; + finishReason?: string; + usage?: StepUsage; + timing?: { llm_first_token_ms?: number; llm_stream_duration_ms?: number }; + interrupted?: { time?: number; reason?: string; message?: string }; + dropped: boolean; + firstRecordIndex: number; + texts: TextAcc[]; + openText?: TextAcc; + textSeq: { a: number; h: number }; + toolCalls: ToolAcc[]; + interactions: InteractionAcc[]; + systems: SystemAcc[]; +} + +interface UserAcc { + turn: TurnAcc; + promptId?: string; + seq: number; + text: string; + acceptedTime?: number; + steeredAt?: string; + origin?: UserMessageOrigin; + notification?: TaskNotificationPayload; + skillActivations?: SkillActivation[]; + sortTime?: number; +} + +interface TurnAcc { + ordinal: number; + origin: TurnOrigin; + promptId?: string; + startedAt?: number; + endedAt?: number; + durationMs?: number; + firstRecordIndex: number; + lastRecordIndex: number; + steps: StepAcc[]; + users: UserAcc[]; +} + +interface TaskAcc { + taskId: string; + startedInfo?: Record; + startedTime?: number; + terminatedInfo?: Record; + outputTail?: string; + terminatedTime?: number; + lastTime?: number; +} + +function iso(time: number | undefined): string { + return new Date(time ?? 0).toISOString(); +} + +function asTime(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asText(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function isDisplayableTurnOrigin(origin: TurnOrigin): boolean { + return ( + origin.kind === 'user' || + origin.kind === 'cron' || + origin.kind === 'side' || + origin.kind === 'task' || + origin.kind === 'skill' + ); +} + +function userDisplayText(input: unknown, origin: unknown): string { + const parts = Array.isArray(input) ? input : []; + const o = origin as { kind?: string; skillActivations?: unknown[] } | undefined; + const bundled = o?.kind === 'user' ? (o.skillActivations?.length ?? 0) : 0; + return textFromContent(parts.slice(bundled)); +} + +function parseNotificationXmlText(text: string): TaskNotificationPayload | undefined { + const match = text.match(/^]*)>\n?/); + if (!match) return undefined; + const attrs = match[1]!; + const attr = (name: string): string | undefined => attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1]; + const rest = text.slice(match[0].length).replace(/\n?<\/notification>\s*$/, ''); + let title = ''; + let severity: string | undefined; + const bodyLines: string[] = []; + for (const line of rest.split('\n')) { + if (line.startsWith('Title: ')) title = line.slice('Title: '.length); + else if (line.startsWith('Severity: ')) severity = line.slice('Severity: '.length); + else bodyLines.push(line); + } + return { + title, + body: bodyLines.join('\n').replace(/^\n+|\n+$/g, ''), + severity, + type: attr('type'), + source_kind: attr('source_kind'), + source_id: attr('source_id'), + agent_id: attr('agent_id'), + raw: text, + }; +} + +interface LoopEventPayload { + type: string; + uuid?: string; + turnId?: string; + step?: number; + stepUuid?: string; + finishReason?: string; + usage?: unknown; + llmFirstTokenLatencyMs?: number; + llmStreamDurationMs?: number; + part?: unknown; + toolCallId?: string; + name?: string; + args?: unknown; + extras?: Record; + result?: Record; +} + +export function buildColdHistory( + sessionId: string, + agentId: string, + records: readonly ColdWireRecord[], + query: ColdHistoryQuery = {}, +): ColdHistoryPage { + const prompts = new Map(); + const turns: TurnAcc[] = []; + const tools = new Map(); + const stepByUuid = new Map(); + const interactions = new Map(); + const tasks = new Map(); + const looseSystems: SystemAcc[] = []; + const allSteps: StepAcc[] = []; + let latestStep: StepAcc | undefined; + let floorIndex = -1; + let floorTime: number | undefined; + let compactionRecord: ColdWireRecord | undefined; + let todoId: string | undefined; + let todoSeq = 0; + + const turnByOrdinal = (ordinal: number): TurnAcc | undefined => turns.find((turn) => turn.ordinal === ordinal); + const latestTurn = (): TurnAcc | undefined => turns.at(-1); + + const sealOpenText = (step: StepAcc): void => { + if (!step.openText) return; + step.texts.push(step.openText); + step.openText = undefined; + }; + + const stepForContent = (uuid: unknown): StepAcc | undefined => { + if (typeof uuid === 'string') { + const hit = stepByUuid.get(uuid); + if (hit) return hit; + } + return latestStep; + }; + + for (let recordIndex = 0; recordIndex < records.length; recordIndex++) { + const record = records[recordIndex]!; + const time = asTime(record.time); + switch (record.type) { + case 'prompt.accepted': { + const promptId = asText(record.promptId); + if (!promptId) break; + prompts.set(promptId, { acceptedTime: time, createdAt: asText(record.createdAt) }); + break; + } + case 'turn.prompt': { + const originValue = record.origin; + const origin = toTurnOrigin(originValue); + const turn: TurnAcc = { + ordinal: turns.length, + origin, + promptId: asText(record.promptId), + startedAt: time, + firstRecordIndex: recordIndex, + lastRecordIndex: recordIndex, + steps: [], + users: [], + }; + turns.push(turn); + if (isDisplayableTurnOrigin(origin)) { + const rawText = userDisplayText(record.input, originValue); + const notification = origin.kind === 'task' ? parseNotificationXmlText(rawText) : undefined; + turn.users.push({ + turn, + promptId: turn.promptId, + seq: 0, + text: notification ? `${notification.title}\n${notification.body}`.trim() : rawText, + acceptedTime: time, + origin: toUserOrigin(originValue), + notification, + skillActivations: toSkillActivations(originValue), + }); + } + break; + } + case 'turn.steer': { + const turn = latestTurn(); + if (!turn) break; + const origin = toUserOrigin(record.origin); + if (origin?.kind === 'skill' && origin.trigger !== 'user-slash') break; + const rawText = textFromContent(record.input); + const notification = origin?.kind === 'task' ? parseNotificationXmlText(rawText) : undefined; + turn.users.push({ + turn, + seq: turn.users.length, + text: notification ? `${notification.title}\n${notification.body}`.trim() : rawText, + acceptedTime: time, + steeredAt: iso(time), + origin, + notification, + sortTime: time, + }); + turn.lastRecordIndex = recordIndex; + break; + } + case 'context.append_message': { + const message = asRecord(record.message); + const origin = toUserOrigin(message?.['origin']); + if (origin?.kind !== 'task') break; + const turn = latestTurn(); + if (!turn) break; + const rawText = textFromContent(message?.['content']); + const notification = parseNotificationXmlText(rawText); + turn.users.push({ + turn, + seq: turn.users.length, + text: notification ? `${notification.title}\n${notification.body}`.trim() : rawText, + acceptedTime: time, + origin, + notification, + sortTime: time, + }); + turn.lastRecordIndex = recordIndex; + break; + } + case 'prompt.completed': + case 'prompt.aborted': { + const promptId = asText(record.promptId); + const prompt = promptId ? prompts.get(promptId) : undefined; + if (prompt && prompt.finishedAt === undefined) { + prompt.finishedAt = asText(record.finishedAt) ?? asText(record.abortedAt) ?? iso(time); + } + if (record.type === 'prompt.aborted') { + const turn = turns.find((candidate) => candidate.promptId === promptId) ?? latestTurn(); + looseSystems.push({ + subtype: 'interruption', + payload: { reason: 'aborted', turn_id: turn ? `t${turn.ordinal + 1}` : undefined }, + time, + recordIndex, + }); + } + break; + } + case 'context.append_loop_event': { + const event = record.event as LoopEventPayload | undefined; + if (!event) break; + const eventType = event.type; + if (eventType === 'step.begin') { + const parsed = typeof event.turnId === 'string' ? Number.parseInt(event.turnId, 10) : undefined; + const turn = (parsed !== undefined && Number.isInteger(parsed) ? turnByOrdinal(parsed) : undefined) ?? latestTurn(); + if (!turn) break; + const ordinal = typeof event.step === 'number' ? event.step - 1 : turn.steps.length; + const open = turn.steps.at(-1); + if (open && open.endTime === undefined && !open.interrupted) { + if (open.dropped && ordinal === open.ordinal) { + open.dropped = false; + open.uuid = event.uuid ?? open.uuid; + open.texts = []; + open.openText = undefined; + open.textSeq = { a: 0, h: 0 }; + if (open.uuid) stepByUuid.set(open.uuid, open); + turn.lastRecordIndex = recordIndex; + latestStep = open; + break; + } + open.dropped = true; + } + const step: StepAcc = { + turn, + uuid: event.uuid, + ordinal, + beginTime: time, + dropped: false, + firstRecordIndex: recordIndex, + texts: [], + textSeq: { a: 0, h: 0 }, + toolCalls: [], + interactions: [], + systems: [], + }; + turn.steps.push(step); + turn.lastRecordIndex = recordIndex; + if (step.uuid) stepByUuid.set(step.uuid, step); + allSteps.push(step); + latestStep = step; + } else if (eventType === 'step.end') { + const step = stepForContent(event.uuid); + if (!step) break; + const finishReason = event.finishReason; + if (finishReason === 'interrupted' || finishReason === 'error') { + step.dropped = true; + } else { + sealOpenText(step); + step.endTime = time; + step.finishReason = finishReason; + step.usage = toStepUsage(event.usage); + const timing = { + llm_first_token_ms: asTime(event.llmFirstTokenLatencyMs), + llm_stream_duration_ms: asTime(event.llmStreamDurationMs), + }; + step.timing = + timing.llm_first_token_ms !== undefined || timing.llm_stream_duration_ms !== undefined ? timing : undefined; + } + step.turn.lastRecordIndex = recordIndex; + } else if (eventType === 'content.part') { + const step = stepForContent(event.stepUuid); + if (!step || step.dropped) break; + const part = asRecord(event.part); + const partType = asText(part?.['type']); + const kind = partType === 'think' ? 'thinking' : partType === 'text' ? 'assistant' : undefined; + if (!kind) break; + const text = kind === 'thinking' ? asText(part?.['think']) ?? '' : asText(part?.['text']) ?? ''; + if (text.length === 0) break; + if (!step.openText || step.openText.kind !== kind) { + sealOpenText(step); + const seq = kind === 'assistant' ? step.textSeq.a++ : step.textSeq.h++; + step.openText = { kind, seq, text: '', firstTime: time, lastTime: time }; + } + step.openText.text += text; + step.openText.lastTime = time; + } else if (eventType === 'tool.call') { + const step = stepForContent(event.stepUuid); + if (!step) break; + sealOpenText(step); + const toolCallId = event.toolCallId; + if (!toolCallId) break; + const normalized = normalizeTodoToolCall(event.name ?? '', event.args); + const acc: ToolAcc = { + toolCallId, + name: normalized.name, + input: normalized.input, + callTime: time, + agentRefs: event.extras?.['agentRefs'] as ToolCallAgentRef[] | undefined, + }; + if (acc.name === 'TodoWrite' && todoItemsFromInput(acc.input) !== undefined) { + todoId = todoId ?? `td_${String(++todoSeq).padStart(2, '0')}`; + acc.todoId = todoId; + } + step.toolCalls.push(acc); + tools.set(toolCallId, { acc, step }); + } else if (eventType === 'tool.result') { + const toolCallId = event.toolCallId; + const hit = toolCallId ? tools.get(toolCallId) : undefined; + if (!hit) break; + const result = event.result; + hit.acc.isError = result?.['isError'] === true; + hit.acc.output = result?.['output']; + hit.acc.resultTime = time; + hit.step.turn.lastRecordIndex = recordIndex; + } + break; + } + case 'turn.step.interrupted': { + const ordinal = asTime(record.turnId); + const turn = ordinal !== undefined ? turnByOrdinal(ordinal) : latestTurn(); + if (!turn) break; + const stepOrdinal = asTime(record.step); + const step = turn.steps.find((candidate) => candidate.ordinal === (stepOrdinal === undefined ? undefined : stepOrdinal - 1)) ?? turn.steps.at(-1); + if (!step) break; + sealOpenText(step); + step.dropped = false; + step.interrupted = { + time, + reason: asText(record.reason), + message: asText(record.message), + }; + turn.lastRecordIndex = recordIndex; + break; + } + case 'turn.ended': { + const ordinal = asTime(record.turnId); + const turn = ordinal !== undefined ? turnByOrdinal(ordinal) : latestTurn(); + if (!turn) break; + turn.endedAt = time; + turn.durationMs = asTime(record.durationMs); + turn.lastRecordIndex = recordIndex; + break; + } + case 'interaction.request': { + const id = asText(record.id); + if (!id) break; + const kind = record.kind === 'question' ? ('question' as const) : ('approval' as const); + const toolCallId = asText(record.toolCallId); + const toolArgs = toolCallId !== undefined ? tools.get(toolCallId)?.acc.input : undefined; + const acc: InteractionAcc = { id, kind, toolCallId, request: toWireInteractionRequest(kind, record.request, toolArgs), requestTime: time }; + const toolStep = toolCallId !== undefined ? tools.get(toolCallId)?.step : undefined; + const step = toolStep ?? latestStep; + if (!step) break; + step.interactions.push(acc); + interactions.set(id, acc); + if (toolCallId !== undefined) { + const tool = tools.get(toolCallId); + if (tool) tool.acc.approvalId = id; + } + break; + } + case 'interaction.resolved': { + const id = asText(record.id); + const acc = id ? interactions.get(id) : undefined; + if (!acc) break; + acc.response = toWireInteractionResponse(acc.kind, record.response); + acc.resolvedTime = time; + break; + } + case 'task.started': { + const info = asRecord(record.info); + const taskId = asText(info?.['taskId']); + if (!taskId) break; + tasks.set(taskId, { taskId, startedInfo: info, startedTime: time, lastTime: time }); + break; + } + case 'task.terminated': { + const info = asRecord(record.info); + const taskId = asText(info?.['taskId']); + if (!taskId) break; + const prev = tasks.get(taskId); + tasks.set(taskId, { + taskId, + startedInfo: prev?.startedInfo, + startedTime: prev?.startedTime, + terminatedInfo: info, + outputTail: asText(record.outputTail), + terminatedTime: time, + lastTime: time, + }); + break; + } + case 'context.undo': + break; + case 'context.undone': { + const fromTurnId = asTime(record.fromTurnId); + looseSystems.push({ + subtype: 'undo', + payload: fromTurnId !== undefined ? { undo_turn_id: `t${fromTurnId + 1}` } : {}, + time, + recordIndex, + }); + break; + } + case 'goal.create': { + looseSystems.push({ + subtype: 'goal', + payload: { status: 'active', objective: asText(record['objective']) }, + time, + recordIndex, + }); + break; + } + case 'goal.update': + case 'goal.clear': { + break; + } + case 'context.apply_compaction': { + floorIndex = recordIndex; + floorTime = time; + compactionRecord = record; + break; + } + case 'context.clear': { + floorIndex = recordIndex; + floorTime = time; + compactionRecord = undefined; + break; + } + default: + break; + } + } + + for (const system of looseSystems) { + const step = allSteps.findLast((candidate) => !candidate.dropped && candidate.firstRecordIndex <= system.recordIndex); + (step ?? latestStep)?.systems.push(system); + } + + const systemIds = new Map(); + let compactionSystemId: string | undefined; + { + const ordered: { recordIndex: number; acc?: SystemAcc }[] = looseSystems.map((acc) => ({ recordIndex: acc.recordIndex, acc })); + if (compactionRecord !== undefined) ordered.push({ recordIndex: floorIndex }); + ordered.sort((a, b) => a.recordIndex - b.recordIndex); + let seq = 0; + for (const entry of ordered) { + seq += 1; + const id = `m_${String(seq).padStart(2, '0')}`; + if (entry.acc) systemIds.set(entry.acc, id); + else compactionSystemId = id; + } + } + + const keptTurns = query.beforeTurn !== undefined ? turns : turns.filter((turn) => turn.lastRecordIndex > floorIndex); + + interface FlatUnit { + pos: number; + seq: number; + items: ServerMessage[]; + turnOrdinal?: number; + stepGroups: { stepId: string; coverOffset: number; itemCount: number }[]; + } + const units: FlatUnit[] = []; + let unitSeq = 0; + + const userMessageFor = (user: UserAcc, turnId: string): ServerMessage => { + const prompt = user.promptId !== undefined ? prompts.get(user.promptId) : undefined; + const finishedAt = prompt?.finishedAt ?? (user.turn.endedAt !== undefined ? iso(user.turn.endedAt) : undefined); + return { + type: 'user', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(prompt?.acceptedTime ?? user.acceptedTime), + message_id: `${turnId}.u${user.seq}`, + turn_id: turnId, + text: user.text, + status: finishedAt !== undefined ? 'completed' : 'running', + created_at: prompt?.createdAt ?? iso(prompt?.acceptedTime ?? user.acceptedTime), + finished_at: finishedAt, + steered_at: user.steeredAt, + origin: user.origin, + notification: user.notification, + skill_activations: user.skillActivations, + } as ServerMessage; + }; + + if (compactionRecord !== undefined) { + const through = (() => { + const prior = turns.filter((turn) => turn.lastRecordIndex < floorIndex); + const highest = prior.length > 0 ? Math.max(...prior.map((turn) => turn.ordinal)) : -1; + return highest >= 0 ? `t${highest + 1}` : undefined; + })(); + units.push({ + pos: Number.NEGATIVE_INFINITY, + seq: unitSeq++, + items: [ + { + type: 'system', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(asTime(compactionRecord.time)), + system_id: compactionSystemId ?? 'm_01', + subtype: 'compaction', + payload: { + before_tokens: asTime(compactionRecord.tokensBefore) ?? 0, + after_tokens: asTime(compactionRecord.tokensAfter) ?? 0, + summarized_through_turn: through, + }, + } as ServerMessage, + ], + stepGroups: [], + }); + } + + for (const turn of keptTurns) { + const turnId = `t${turn.ordinal + 1}`; + const groupItems: ServerMessage[] = []; + const stepGroups: { stepId: string; coverOffset: number; itemCount: number }[] = []; + if (turn.endedAt !== undefined) { + let usageInput = 0; + let usageOutput = 0; + for (const step of turn.steps) { + if (step.dropped || step.endTime === undefined || !step.usage) continue; + usageInput += step.usage.input_other; + usageOutput += step.usage.output; + } + groupItems.push({ + type: 'turn', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(turn.endedAt + COVER_SEAL_OFFSET_MS), + turn_id: turnId, + ordinal: turn.ordinal, + state: 'completed', + origin: turn.origin, + user_message_id: turn.users.length > 0 ? `${turnId}.u${turn.users[0]!.seq}` : undefined, + started_at: iso(turn.startedAt), + ended_at: iso(turn.endedAt), + usage: usageInput > 0 || usageOutput > 0 ? { input_tokens: usageInput, output_tokens: usageOutput } : undefined, + duration_ms: turn.durationMs, + } as ServerMessage); + } + const visibleSteps = turn.steps.filter((step) => !step.dropped && (step.endTime !== undefined || step.interrupted)); + const timedUsers = turn.users + .filter((candidate) => candidate.sortTime !== undefined) + .slice() + .sort((a, b) => (a.sortTime ?? 0) - (b.sortTime ?? 0)); + let timedUserCursor = 0; + let openingUsersPlaced = false; + for (const step of visibleSteps) { + const stepId = `${turnId}.${step.ordinal}`; + const stepItems: ServerMessage[] = []; + const sealTime = step.interrupted ? (step.interrupted.time ?? step.beginTime) : (step.endTime ?? step.beginTime); + const sealTimestamp = sealTime === undefined ? undefined : iso(sealTime + COVER_SEAL_OFFSET_MS); + if (step.interrupted) { + stepItems.push({ + type: 'step', + session_id: sessionId, + agent_id: agentId, + timestamp: sealTimestamp ?? iso(step.beginTime), + step_id: stepId, + turn_id: turnId, + ordinal: step.ordinal, + state: 'interrupted', + started_at: iso(step.beginTime), + ended_at: iso(step.interrupted.time), + end_reason: step.interrupted.reason, + end_message: step.interrupted.message, + } as ServerMessage); + } else { + stepItems.push({ + type: 'step', + session_id: sessionId, + agent_id: agentId, + timestamp: sealTimestamp ?? iso(step.beginTime), + step_id: stepId, + turn_id: turnId, + ordinal: step.ordinal, + state: 'completed', + started_at: iso(step.beginTime), + ended_at: iso(step.endTime), + usage: step.usage, + finish_reason: step.finishReason, + timing: step.timing, + } as ServerMessage); + } + const content: { time: number; msg: ServerMessage }[] = []; + if (!openingUsersPlaced) { + for (const user of turn.users.filter((candidate) => candidate.sortTime === undefined)) { + content.push({ time: step.beginTime ?? 0, msg: userMessageFor(user, turnId) }); + } + openingUsersPlaced = true; + } + while (timedUserCursor < timedUsers.length) { + const user = timedUsers[timedUserCursor]!; + const userTime = user.sortTime ?? 0; + const isLastStep = step === visibleSteps[visibleSteps.length - 1]; + if (!isLastStep && step.endTime !== undefined && userTime > step.endTime) break; + content.push({ time: userTime, msg: userMessageFor(user, turnId) }); + timedUserCursor += 1; + } + for (const text of step.texts) { + content.push({ + time: text.firstTime ?? 0, + msg: { + type: text.kind, + session_id: sessionId, + agent_id: agentId, + timestamp: iso(text.lastTime), + message_id: `${stepId}.${text.kind === 'assistant' ? 'a' : 'h'}${text.seq}`, + turn_id: turnId, + step_id: stepId, + status: 'completed', + text: text.text, + } as ServerMessage, + }); + } + for (const tool of step.toolCalls) { + const isError = tool.isError === true; + const display = + tool.approvalId === undefined + ? undefined + : (interactions.get(tool.approvalId)?.request as { display?: unknown } | undefined)?.display; + content.push({ + time: tool.callTime ?? 0, + msg: { + type: 'tool_call', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(tool.resultTime ?? tool.callTime), + tool_call_id: tool.toolCallId, + turn_id: turnId, + step_id: stepId, + name: tool.name, + state: isError ? 'error' : 'done', + input: tool.input, + output: isError ? undefined : tool.output, + error: isError ? (typeof tool.output === 'string' ? tool.output : JSON.stringify(tool.output)) : undefined, + display, + approval_id: tool.approvalId, + todo_id: tool.todoId, + agent_refs: tool.agentRefs, + } as ServerMessage, + }); + } + for (const interaction of step.interactions) { + const response = interaction.response; + const state = + interaction.kind === 'question' + ? response === null || response === undefined + ? ('dismissed' as const) + : ('answered' as const) + : ((): 'approved' | 'rejected' | 'cancelled' => { + const decision = asRecord(response)?.['decision']; + return decision === 'approved' || decision === 'rejected' || decision === 'cancelled' ? decision : 'cancelled'; + })(); + content.push({ + time: interaction.requestTime ?? 0, + msg: { + type: 'interaction', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(interaction.resolvedTime ?? interaction.requestTime), + interaction_id: interaction.id, + kind: interaction.kind, + state, + tool_call_id: interaction.toolCallId, + request: interaction.request, + response: interaction.response, + } as ServerMessage, + }); + } + for (const system of step.systems) { + content.push({ + time: system.time ?? 0, + msg: { + type: 'system', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(system.time), + system_id: systemIds.get(system) ?? 'm_01', + subtype: system.subtype, + payload: system.payload, + } as ServerMessage, + }); + } + content.sort((a, b) => a.time - b.time); + stepItems.push(...content.map((entry) => entry.msg)); + stepGroups.push({ stepId, coverOffset: groupItems.length, itemCount: stepItems.length }); + groupItems.push(...stepItems); + } + units.push({ pos: turn.startedAt ?? 0, seq: unitSeq++, items: groupItems, turnOrdinal: turn.ordinal, stepGroups }); + } + + for (const task of tasks.values()) { + const lastTime = task.terminatedTime ?? task.lastTime ?? task.startedTime; + if (query.beforeTurn === undefined && lastTime !== undefined && floorTime !== undefined && lastTime <= floorTime) continue; + const startedInfo = task.startedInfo; + const terminatedInfo = task.terminatedInfo; + units.push({ + pos: lastTime ?? 0, + seq: unitSeq++, + items: [ + { + type: 'task', + session_id: sessionId, + agent_id: agentId, + timestamp: iso(lastTime), + task_id: task.taskId, + kind: toTaskKind(asText(terminatedInfo?.['kind']) ?? asText(startedInfo?.['kind'])), + state: (asText(terminatedInfo?.['status']) ?? 'running') as TaskMessage['state'], + detached: startedInfo?.['detached'] === true || terminatedInfo?.['detached'] === true, + description: asText(terminatedInfo?.['description']) ?? asText(startedInfo?.['description']), + output_tail: task.outputTail ?? asText(terminatedInfo?.['outputTail']) ?? '', + started_at: asText(startedInfo?.['startedAt']) ?? iso(task.startedTime), + ended_at: terminatedInfo !== undefined ? asText(terminatedInfo['endedAt']) ?? iso(task.terminatedTime) : undefined, + result_summary: asText(terminatedInfo?.['resultSummary']), + error: asText(terminatedInfo?.['error']), + state_reason: asText(terminatedInfo?.['stopReason']), + usage: toStepUsage(terminatedInfo?.['usage']), + model: asText(startedInfo?.['model']), + thinking_effort: asText(startedInfo?.['thinkingEffort']), + child_agent_id: asText(terminatedInfo?.['childAgentId']) ?? asText(startedInfo?.['childAgentId']), + } as ServerMessage, + ], + stepGroups: [], + }); + } + + units.sort((a, b) => a.pos - b.pos || a.seq - b.seq); + + const lastTurn = turns.at(-1); + let inFlight: ColdInFlight | null = null; + if (lastTurn && lastTurn.endedAt === undefined && lastTurn.lastRecordIndex > floorIndex) { + const lastStep = lastTurn.steps.at(-1); + inFlight = { + turn_id: `t${lastTurn.ordinal + 1}`, + step_id: lastStep ? `t${lastTurn.ordinal + 1}.${lastStep.ordinal}` : undefined, + }; + } + + const pageSize = query.pageSize ?? DEFAULT_PAGE_SIZE; + + if (query.afterStep !== undefined) { + const items: ServerMessage[] = []; + let found = false; + for (const unit of units) { + if (!found) { + const groupIndex = unit.stepGroups.findIndex((group) => group.stepId === query.afterStep); + if (groupIndex < 0) continue; + found = true; + const skip = + unit.stepGroups[0]!.coverOffset + unit.stepGroups.slice(0, groupIndex + 1).reduce((sum, group) => sum + group.itemCount, 0); + items.push(...unit.items.slice(skip)); + continue; + } + items.push(...unit.items); + } + return { session_id: sessionId, items, has_more: false, in_flight: inFlight }; + } + + if (query.beforeTurn !== undefined) { + const unitIndex = units.findIndex((unit) => unit.turnOrdinal !== undefined && `t${unit.turnOrdinal + 1}` === query.beforeTurn); + if (unitIndex < 0) return { session_id: sessionId, items: [], has_more: false, in_flight: inFlight }; + const before = units.slice(0, unitIndex); + const turnUnits = before.filter((unit) => unit.turnOrdinal !== undefined); + const startIndex = Math.max(0, turnUnits.length - pageSize); + const kept = new Set(turnUnits.slice(startIndex)); + const items = before.filter((unit) => unit.turnOrdinal === undefined || kept.has(unit)).flatMap((unit) => unit.items); + return { session_id: sessionId, items, has_more: startIndex > 0, in_flight: inFlight }; + } + + const turnUnits = units.filter((unit) => unit.turnOrdinal !== undefined); + const startIndex = Math.max(0, turnUnits.length - pageSize); + const kept = new Set(turnUnits.slice(startIndex)); + const items = units.filter((unit) => unit.turnOrdinal === undefined || kept.has(unit)).flatMap((unit) => unit.items); + const floorDropped = + turns.length - keptTurns.length > 0 || + [...tasks.values()].some((task) => floorTime !== undefined && (task.lastTime ?? 0) <= floorTime); + return { + session_id: sessionId, + items, + has_more: startIndex > 0 || (floorIndex >= 0 && floorDropped), + in_flight: inFlight, + }; +} diff --git a/packages/kap-server/src/services/v2Projection/globalFanout.ts b/packages/kap-server/src/services/v2Projection/globalFanout.ts new file mode 100644 index 00000000000..4c9ad6f55aa --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/globalFanout.ts @@ -0,0 +1,178 @@ +import { + serverMessageSchema, + type ServerMessage, + type SessionInfo, + type WorkspaceInfo, +} from '../../protocol/v2/messages/index'; +import type { V2Disposable } from './binder'; + +export interface GlobalV2Event { + type: string; + payload?: unknown; + time?: number; +} + +export interface GlobalV2EventSource { + subscribe(handler: (event: GlobalV2Event) => void): V2Disposable; +} + +export interface GlobalV2FanoutDeps { + sessionInfoFor(sessionId: string): Promise; + workspaceWireFor(workspace: unknown): Promise; + ensureSessionBinding?(sessionId: string): void; + clock?: () => number; + logger?: { warn(meta: Record, msg: string): void }; +} + +function iso(time: number): string { + return new Date(time).toISOString(); +} + +function payloadOf(event: GlobalV2Event): Record | undefined { + const payload = event.payload; + return payload !== null && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as Record) + : undefined; +} + +function textOf(payload: Record | undefined, key: string): string | undefined { + const value = payload?.[key]; + return typeof value === 'string' ? value : undefined; +} + +export class GlobalV2Fanout { + private readonly targets = new Set<(msg: ServerMessage) => void>(); + private readonly clock: () => number; + + constructor( + source: GlobalV2EventSource, + private readonly deps: GlobalV2FanoutDeps, + ) { + this.clock = deps.clock ?? Date.now; + source.subscribe((event) => { + void this.onEvent(event).catch((error: unknown) => + deps.logger?.warn({ err: error, type: event.type }, 'ws2 global fanout failed'), + ); + }); + } + + addTarget(target: (msg: ServerMessage) => void): V2Disposable { + this.targets.add(target); + return { dispose: () => this.targets.delete(target) }; + } + + private emit(msg: ServerMessage): void { + const parsed = serverMessageSchema.safeParse(msg); + if (!parsed.success) { + this.deps.logger?.warn({ type: msg.type }, 'ws2 global outbound dropped: contract violation'); + return; + } + for (const target of this.targets) target(parsed.data as ServerMessage); + } + + private async onEvent(event: GlobalV2Event): Promise { + const payload = payloadOf(event); + const ts = iso(event.time ?? this.clock()); + switch (event.type) { + case 'session.meta.updated': { + const sessionId = textOf(payload, 'sessionId'); + if (sessionId === undefined) return; + const session = await this.deps.sessionInfoFor(sessionId); + if (session === undefined) return; + const patch = payload?.['patch']; + const changedFields = + patch !== null && typeof patch === 'object' && !Array.isArray(patch) + ? Object.keys(patch) + : textOf(payload, 'title') !== undefined + ? ['title'] + : undefined; + this.emit({ type: 'session', timestamp: ts, subtype: 'updated', session, changed_fields: changedFields }); + return; + } + case 'event.session.created': { + const sessionId = textOf(payload, 'sessionId'); + if (sessionId === undefined) return; + this.deps.ensureSessionBinding?.(sessionId); + const session = await this.deps.sessionInfoFor(sessionId); + if (session === undefined) return; + this.emit({ type: 'session', timestamp: ts, subtype: 'created', session }); + return; + } + case 'event.session.archived': { + const sessionId = textOf(payload, 'sessionId'); + if (sessionId === undefined) return; + const session = await this.deps.sessionInfoFor(sessionId); + if (session === undefined) return; + this.emit({ type: 'session', timestamp: ts, subtype: 'archived', session }); + return; + } + case 'event.workspace.created': + case 'event.workspace.updated': { + const workspace = await this.deps.workspaceWireFor(payload?.['workspace']); + if (workspace === undefined) return; + this.emit({ + type: 'workspace', + timestamp: ts, + subtype: event.type === 'event.workspace.created' ? 'created' : 'updated', + workspace, + }); + return; + } + case 'event.workspace.deleted': { + const workspaceId = textOf(payload, 'workspaceId'); + const root = textOf(payload, 'root') ?? ''; + if (workspaceId === undefined) return; + const workspace = (await this.deps.workspaceWireFor(payload?.['workspace'])) ?? { + id: workspaceId, + root, + name: root.length > 0 ? (root.split('/').findLast((segment) => segment.length > 0) ?? root) : workspaceId, + created_at: ts, + last_opened_at: ts, + session_count: 0, + }; + this.emit({ type: 'workspace', timestamp: ts, subtype: 'deleted', workspace }); + return; + } + case 'event.config.changed': { + const changedFields = payload?.['changedFields']; + this.emit({ + type: 'config', + timestamp: ts, + config: (payload?.['config'] ?? {}) as Record, + changed_fields: Array.isArray(changedFields) ? (changedFields as string[]) : undefined, + }); + return; + } + case 'event.config.warning': { + const warnings = payload?.['warnings']; + if (!Array.isArray(warnings)) return; + this.emit({ + type: 'config.warning', + timestamp: ts, + warnings: warnings + .map((warning) => { + const message = textOf(warning as Record, 'message'); + const domain = textOf(warning as Record, 'domain'); + return domain !== undefined ? `${domain}: ${message}` : message; + }) + .filter((warning): warning is string => warning !== undefined), + }); + return; + } + case 'event.model_catalog.changed': { + this.emit({ type: 'model_catalog', timestamp: ts }); + return; + } + case 'event.plugin.changed': { + this.emit({ type: 'plugin', timestamp: ts }); + return; + } + case 'event.capability.changed': { + this.emit({ type: 'capability', timestamp: ts, capability_id: textOf(payload, 'capability_id') }); + return; + } + default: + return; + } + } +} diff --git a/packages/kap-server/src/services/v2Projection/index.ts b/packages/kap-server/src/services/v2Projection/index.ts new file mode 100644 index 00000000000..be3c7ed5b5e --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/index.ts @@ -0,0 +1,3 @@ +export * from './agentProjector'; +export * from './sessionStateComposer'; +export * from './sessionProjector'; diff --git a/packages/kap-server/src/services/v2Projection/interactionWire.ts b/packages/kap-server/src/services/v2Projection/interactionWire.ts new file mode 100644 index 00000000000..2a9c9ff8e26 --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/interactionWire.ts @@ -0,0 +1,71 @@ +import type { + ApprovalRequest, + ApprovalResponsePayload, + QuestionRequest, + QuestionResponsePayload, +} from '../../protocol/v2/messages/index'; + +function toWireOptions(options: unknown): string[] | undefined { + if (!Array.isArray(options)) return undefined; + const out: string[] = []; + for (const option of options) { + if (typeof option === 'string') out.push(option); + else { + const label = (option as { label?: unknown } | undefined)?.label; + if (typeof label === 'string') out.push(label); + } + } + return out.length > 0 ? out : undefined; +} + +export function toWireInteractionRequest( + kind: 'approval' | 'question', + payload: unknown, + toolArgs?: unknown, +): ApprovalRequest | QuestionRequest | undefined { + const p = payload as Record | null | undefined; + if (!p) return undefined; + if (kind === 'approval') { + const toolName = p['toolName'] ?? p['tool_name']; + if (typeof toolName !== 'string') return undefined; + const reason = p['reason'] ?? p['action']; + return { + tool_name: toolName, + input: toolArgs ?? p['input'], + reason: typeof reason === 'string' ? reason : undefined, + display: p['display'], + }; + } + const questions = p['questions']; + if (!Array.isArray(questions)) return undefined; + const mapped: QuestionRequest['questions'] = []; + for (let i = 0; i < questions.length; i++) { + const q = questions[i] as { id?: unknown; question?: unknown; options?: unknown } | undefined; + if (typeof q?.question !== 'string') return undefined; + mapped.push({ + id: typeof q.id === 'string' ? q.id : `q_${i}`, + question: q.question, + options: toWireOptions(q.options), + }); + } + return { questions: mapped }; +} + +export function toWireInteractionResponse( + kind: 'approval' | 'question', + response: unknown, +): ApprovalResponsePayload | QuestionResponsePayload | undefined { + if (response === null || response === undefined) return undefined; + const r = response as Record; + if (kind === 'approval') { + const decision = r['decision']; + return { + decision: decision === 'approved' || decision === 'rejected' ? decision : 'cancelled', + feedback: typeof r['feedback'] === 'string' ? r['feedback'] : undefined, + }; + } + const answers = r['answers']; + return { + answers: (answers !== null && typeof answers === 'object' ? answers : {}) as Record, + }; +} diff --git a/packages/kap-server/src/services/v2Projection/sessionProjector.ts b/packages/kap-server/src/services/v2Projection/sessionProjector.ts new file mode 100644 index 00000000000..89daefa91b6 --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/sessionProjector.ts @@ -0,0 +1,50 @@ +import type { ServerMessage } from '../../protocol/v2/messages/index'; +import { + AgentV2Projector, + type InteractionPendingRecord, + type InteractionResolvedRecord, + type ProjectionEvent, +} from './agentProjector'; +import { SessionStateComposer, type SessionFactsPatch } from './sessionStateComposer'; + +export class SessionV2Projector { + private readonly agents = new Map(); + readonly composer: SessionStateComposer; + + constructor(readonly sessionId: string) { + this.composer = new SessionStateComposer(sessionId); + } + + agentFor(agentId: string): AgentV2Projector { + let projector = this.agents.get(agentId); + if (!projector) { + const turnIdPrefix = agentId === 'main' || agentId.startsWith('side_') ? 't' : 'r'; + projector = new AgentV2Projector(this.sessionId, agentId, turnIdPrefix); + this.agents.set(agentId, projector); + } + return projector; + } + + applyAgentEvent(agentId: string, event: ProjectionEvent): ServerMessage[] { + return this.agentFor(agentId).apply(event); + } + + applyInteractionPending(agentId: string, record: InteractionPendingRecord): ServerMessage[] { + return this.agentFor(agentId).applyInteractionPending(record); + } + + applyInteractionResolved(agentId: string, record: InteractionResolvedRecord): ServerMessage[] { + return this.agentFor(agentId).applyInteractionResolved(record); + } + + applyFacts(patch: SessionFactsPatch, time: number, flushTexts = true): ServerMessage[] { + const out: ServerMessage[] = []; + if (flushTexts) { + for (const agent of this.agents.values()) out.push(...agent.flushOpenTexts(time)); + } + this.composer.apply(patch); + const msg = this.composer.compose(time, (turnId, step) => `t${turnId + 1}.${step}`); + if (msg) out.push(msg); + return out; + } +} diff --git a/packages/kap-server/src/services/v2Projection/sessionStateComposer.ts b/packages/kap-server/src/services/v2Projection/sessionStateComposer.ts new file mode 100644 index 00000000000..86cf2a027ae --- /dev/null +++ b/packages/kap-server/src/services/v2Projection/sessionStateComposer.ts @@ -0,0 +1,178 @@ +import type { + AgentPhase, + SessionModes, + SessionStateMessage, + SessionStateUsage, + StepUsage, +} from '../../protocol/v2/messages/index'; + +export interface ComposerActivityFact { + busy: boolean; + mainTurnActive: boolean; + pendingInteraction: 'none' | 'approval' | 'question'; + lastTurnReason?: 'completed' | 'cancelled' | 'failed'; +} + +export interface ComposerTurnFact { + turnId: number; + step: number; + phase: 'running' | 'streaming' | 'tool_call' | 'retrying'; + since: number; + pendingApprovals?: readonly unknown[]; +} + +export interface ComposerAgentActivityFact { + lifecycle: 'ready' | 'disposed'; + turn?: ComposerTurnFact; +} + +export interface ComposerStatusFact { + model?: string; + thinkingEffort?: string; + contextTokens?: number; + maxContextTokens?: number; + usage?: { + byModel?: Record; + total?: unknown; + currentTurn?: unknown; + }; +} + +export interface ComposerGoalFact { + objective: string; + status: 'active' | 'paused' | 'blocked' | 'complete'; + completionCriterion?: string; + budgetUsed?: number; + budgetLimit?: number; +} + +export interface SessionFactsPatch { + activity?: ComposerActivityFact; + agentActivity?: ComposerAgentActivityFact; + status?: ComposerStatusFact; + permission?: 'manual' | 'yolo' | 'auto'; + goal?: ComposerGoalFact | null; + modes?: SessionModes; +} + +function toStepUsage(usage: unknown): StepUsage | undefined { + const u = usage as + | { inputOther?: number; output?: number; inputCacheRead?: number; inputCacheCreation?: number } + | undefined; + if (!u) return undefined; + return { + input_other: u.inputOther ?? 0, + output: u.output ?? 0, + input_cache_read: u.inputCacheRead ?? 0, + input_cache_creation: u.inputCacheCreation ?? 0, + }; +} + +export class SessionStateComposer { + private activity?: ComposerActivityFact; + private agentActivity?: ComposerAgentActivityFact; + private status?: ComposerStatusFact; + private permission?: 'manual' | 'yolo' | 'auto'; + private goal?: ComposerGoalFact | null; + private modes?: SessionModes; + private lastJson?: string; + + constructor(private readonly sessionId: string) {} + + hasFacts(): boolean { + return ( + this.activity?.busy === true || + this.agentActivity?.turn !== undefined || + this.status !== undefined || + this.goal !== undefined || + this.modes !== undefined + ); + } + + apply(patch: SessionFactsPatch): void { + if (patch.activity !== undefined) this.activity = patch.activity; + if (patch.agentActivity !== undefined) this.agentActivity = patch.agentActivity; + if (patch.status !== undefined) this.status = { ...this.status, ...patch.status }; + if (patch.permission !== undefined) this.permission = patch.permission; + if (patch.goal !== undefined) this.goal = patch.goal; + this.modes = patch.modes; + } + + compose( + time: number | undefined, + resolveStepId?: (engineTurnId: number, step: number) => string | undefined, + ): SessionStateMessage | null { + const timestamp = new Date(time ?? 0).toISOString(); + const turn = this.agentActivity?.turn; + let phase: AgentPhase | undefined; + if (turn) { + const pendingApprovals = turn.pendingApprovals?.length ?? 0; + if (pendingApprovals > 0 || this.activity?.pendingInteraction === 'approval') { + phase = { kind: 'awaiting_approval', turn_id: turn.turnId, step: turn.step, since: turn.since }; + } else if (this.activity?.pendingInteraction === 'question') { + phase = { kind: 'awaiting_question', turn_id: turn.turnId, step: turn.step, since: turn.since }; + } else { + phase = { + kind: 'running', + turn_id: turn.turnId, + step: turn.step, + step_id: resolveStepId?.(turn.turnId, turn.step), + since: turn.since, + }; + } + } else if (!this.activity?.busy) { + phase = this.agentActivity ? { kind: 'idle' } : undefined; + } + let usage: SessionStateUsage | undefined; + if (this.status?.usage) { + usage = { + by_model: mapByModel(this.status.usage.byModel), + current_turn: toStepUsage(this.status.usage.currentTurn), + total: toStepUsage(this.status.usage.total), + }; + } + const lifecycle = this.agentActivity?.lifecycle; + const busy = this.activity?.busy ?? false; + const msg: SessionStateMessage = { + type: 'session.state', + session_id: this.sessionId, + timestamp, + busy, + main_turn_active: this.activity?.mainTurnActive ?? false, + pending_interaction: this.activity?.pendingInteraction, + last_turn_reason: this.activity?.lastTurnReason, + activity: lifecycle === 'disposed' ? 'disposing' : busy || turn ? 'turn' : 'idle', + phase, + model: this.status?.model, + thinking_effort: this.status?.thinkingEffort, + permission: this.permission, + usage, + context_tokens: this.status?.contextTokens, + max_context_tokens: this.status?.maxContextTokens, + goal: this.goal + ? { + objective: this.goal.objective, + status: this.goal.status, + completion_criterion: this.goal.completionCriterion, + budget_used: this.goal.budgetUsed, + budget_limit: this.goal.budgetLimit, + } + : undefined, + modes: this.modes, + }; + const json = JSON.stringify(msg); + if (json === this.lastJson) return null; + this.lastJson = json; + return msg; + } +} + +function mapByModel(byModel: Record | undefined): Record | undefined { + if (!byModel) return undefined; + const out: Record = {}; + for (const [model, usage] of Object.entries(byModel)) { + const mapped = toStepUsage(usage); + if (mapped) out[model] = mapped; + } + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index a1c3cb0e334..526e3079775 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -31,6 +31,8 @@ import { kimiRegionProfile, type KimiHostIdentity, } from '@moonshot-ai/kimi-code-oauth'; +import { ulid } from 'ulid'; + import { createAsyncApiDocument } from './protocol/asyncapi'; import Fastify, { type FastifyInstance } from 'fastify'; @@ -42,6 +44,9 @@ import { resolveRequestId } from './request-id'; import { registerApiV1Routes } from './routes/registerApiV1Routes'; import { registerApiV2Routes } from './routes/registerApiV2Routes'; import { registerWebAssetRoutes } from './routes/webAssets'; +import { resolveSessionFacts } from './routes/sessions'; +import { toWireWorkspace } from './routes/workspaces'; +import { MAIN_AGENT_ID } from './transport/mainAgent'; import { createServerLogger, type ServerLogger, @@ -61,6 +66,9 @@ import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcast import type { ConfigWarningItem } from './transport/ws/v1/events'; import { FsWatchBridge } from './transport/ws/v1/fsWatchBridge'; import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1'; +import { registerWsV2, WS_PATH_V2 } from './transport/ws/v2/registerWsV2'; +import { liveSessionSourceFor, SessionV2Binder } from './services/v2Projection/binder'; +import { GlobalV2Fanout } from './services/v2Projection/globalFanout'; import { getServerVersion } from './version'; import { classify } from './security/bindClassify'; import { @@ -418,6 +426,8 @@ export async function startServer(opts: ServerStartOptions): Promise { - void close().catch((err: unknown) => logger.error({ err }, 'server close failed')); + void close().catch((error: unknown) => logger.error({ err: error }, 'server close failed')); }, connectionRegistry, broadcaster, transcriptService, dangerousBypassAuth: opts.disableAuth === true, webTitle: opts.webTitle, + serverId, }); await registerApiV2Routes(app, core); @@ -455,6 +466,47 @@ export async function startServer(opts: ServerStartOptions): Promise core.accessor.get(IEventService).subscribe(handler) }, + { + sessionInfoFor: async (sessionId) => { + const summary = await core.accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return undefined; + const facts = resolveSessionFacts(core, sessionId); + const binding = v2Binder.peek(sessionId); + return { + session_id: summary.id, + workspace_id: summary.workspaceId, + title: summary.title ?? '', + status: summary.archived ? 'archived' : 'active', + model: facts.model, + created_at: new Date(summary.createdAt).toISOString(), + updated_at: new Date(summary.updatedAt).toISOString(), + turn_count: binding?.projector.agentFor(MAIN_AGENT_ID).turnCount, + }; + }, + workspaceWireFor: async (workspace) => { + if (workspace === null || typeof workspace !== 'object') return undefined; + return (await toWireWorkspace(core, workspace as Parameters[1])) as never; + }, + ensureSessionBinding: (sessionId) => { + const source = liveSessionSourceFor(core, sessionId); + if (source !== undefined) v2Binder.attach(source); + }, + logger: { warn: (meta, msg) => logger.warn(meta, msg) }, + }, + ); + + const wssV2 = registerWsV2({ + binder: v2Binder, + registry: connectionRegistry, + serverId, + sessionSourceFor: (sessionId) => liveSessionSourceFor(core, sessionId), + globalFanout, + logger: { warn: (meta, msg) => logger.warn(meta, msg) }, + }); + const handleUpgrade = async ( req: IncomingMessage, socket: Duplex, @@ -462,7 +514,8 @@ export async function startServer(opts: ServerStartOptions): Promise => { const url = req.url ?? ''; const isV1 = url === WS_PATH_V1 || url.startsWith(`${WS_PATH_V1}?`); - if (!isV1) { + const isV2 = url === WS_PATH_V2 || url.startsWith(`${WS_PATH_V2}?`); + if (!isV1 && !isV2) { socket.destroy(); return; } @@ -524,7 +577,8 @@ export async function startServer(opts: ServerStartOptions): Promise wssV1.emit('connection', ws, req)); + if (isV2) wssV2.handleUpgrade(req, socket, head, (ws) => wssV2.emit('connection', ws, req)); + else wssV1.handleUpgrade(req, socket, head, (ws) => wssV1.emit('connection', ws, req)); }; app.server.on('upgrade', (req, socket, head) => { void handleUpgrade(req, socket, head).catch((error: unknown) => @@ -534,6 +588,7 @@ export async function startServer(opts: ServerStartOptions): Promise { connectionRegistry.closeAll('server shutting down'); + wssV2.close(); wssV1.close(); await broadcaster.close(); }); diff --git a/packages/kap-server/src/transport/ws/v2/registerWsV2.ts b/packages/kap-server/src/transport/ws/v2/registerWsV2.ts new file mode 100644 index 00000000000..94bb795d7d5 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v2/registerWsV2.ts @@ -0,0 +1,45 @@ +import { WebSocketServer } from 'ws'; + +import type { SessionV2Binder, V2SessionSource } from '../../../services/v2Projection/binder'; +import type { GlobalV2Fanout } from '../../../services/v2Projection/globalFanout'; +import { selectWsBearerProtocol } from '../bearerProtocol'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import { WsConnectionV2, type WsConnectionV2Logger } from './wsConnectionV2'; + +export const WS_PATH_V2 = '/api/v2/ws'; + +export interface RegisterWsV2Options { + readonly binder: SessionV2Binder; + readonly registry: IConnectionRegistry; + readonly serverId: string; + readonly sessionSourceFor: (sessionId: string) => V2SessionSource | undefined; + readonly globalFanout?: GlobalV2Fanout; + readonly clock?: () => number; + readonly outboundCapacity?: number; + readonly inflightWindow?: number; + readonly heartbeatIntervalMs?: number; + readonly logger?: WsConnectionV2Logger; +} + +export function registerWsV2(opts: RegisterWsV2Options): WebSocketServer { + const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + wss.on('connection', (socket, req) => { + const connection = new WsConnectionV2({ + socket, + binder: opts.binder, + registry: opts.registry, + serverId: opts.serverId, + sessionSourceFor: opts.sessionSourceFor, + remoteAddress: req.socket.remoteAddress ?? null, + userAgent: req.headers['user-agent'] ?? null, + globalFanout: opts.globalFanout, + clock: opts.clock, + outboundCapacity: opts.outboundCapacity, + inflightWindow: opts.inflightWindow, + heartbeatIntervalMs: opts.heartbeatIntervalMs, + logger: opts.logger, + }); + socket.on('close', () => opts.registry.remove(connection.id)); + }); + return wss; +} diff --git a/packages/kap-server/src/transport/ws/v2/wsConnectionV2.ts b/packages/kap-server/src/transport/ws/v2/wsConnectionV2.ts new file mode 100644 index 00000000000..455dd808e2c --- /dev/null +++ b/packages/kap-server/src/transport/ws/v2/wsConnectionV2.ts @@ -0,0 +1,239 @@ +import { ulid } from 'ulid'; +import type { WebSocket } from 'ws'; + +import { ErrorCode } from '../../../protocol/error-codes'; +import { clientMessageSchema, serverMessageSchema, type ServerMessage } from '../../../protocol/v2/messages/index'; +import type { IConnectionRegistry } from '../connectionRegistry'; +import type { SessionV2Binder, V2Disposable, V2SessionSource } from '../../../services/v2Projection/binder'; +import type { GlobalV2Fanout } from '../../../services/v2Projection/globalFanout'; + +export const WS_V2_PROTOCOL_VERSION = 2; +export const WS_V2_CAPABILITIES = ['step_replay_v1', 'interaction_v1'] as const; +export const BACKPRESSURE_OVERFLOW_MESSAGE = 'outbound queue overflow; connection closed, reconnect to resync'; + +const DEFAULT_OUTBOUND_CAPACITY = 256; +const DEFAULT_INFLIGHT_WINDOW = 64; +const HEARTBEAT_MISS_LIMIT = 3; + +export interface WsConnectionV2Logger { + warn(meta: Record, msg: string): void; +} + +export interface WsConnectionV2Options { + readonly socket: WebSocket; + readonly binder: SessionV2Binder; + readonly registry: IConnectionRegistry; + readonly serverId: string; + readonly sessionSourceFor: (sessionId: string) => V2SessionSource | undefined; + readonly remoteAddress?: string | null; + readonly userAgent?: string | null; + readonly globalFanout?: GlobalV2Fanout; + readonly clock?: () => number; + readonly outboundCapacity?: number; + readonly inflightWindow?: number; + readonly heartbeatIntervalMs?: number; + readonly logger?: WsConnectionV2Logger; +} + +interface Subscription { + sessionId: string; + agentId: string; + mainChannel: boolean; + omit: ReadonlySet; + agentMessages: V2Disposable; + sessionMessages?: V2Disposable; +} + +export class WsConnectionV2 { + readonly id: string; + readonly connectedAt: string; + readonly remoteAddress: string | null; + readonly userAgent: string | null; + private readonly clock: () => number; + private readonly outboundCapacity: number; + private readonly inflightWindow: number; + private readonly heartbeatIntervalMs: number; + private readonly subscriptions: Subscription[] = []; + private readonly outboundQueue: string[] = []; + private inflight = 0; + private overflowed = false; + private closed = false; + private heartbeatTimer?: ReturnType; + private lastPongAt: number; + private globalTarget?: V2Disposable; + + constructor(private readonly opts: WsConnectionV2Options) { + this.id = ulid(); + this.connectedAt = new Date().toISOString(); + this.remoteAddress = opts.remoteAddress ?? null; + this.userAgent = opts.userAgent ?? null; + this.clock = opts.clock ?? Date.now; + this.outboundCapacity = opts.outboundCapacity ?? DEFAULT_OUTBOUND_CAPACITY; + this.inflightWindow = opts.inflightWindow ?? DEFAULT_INFLIGHT_WINDOW; + this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 30_000; + this.lastPongAt = this.clock(); + const socket = opts.socket; + socket.on('message', (data: unknown) => this.onMessage(data)); + socket.on('close', () => this.onClose()); + socket.on('pong', () => { + this.lastPongAt = this.clock(); + }); + if (this.heartbeatIntervalMs > 0) { + this.heartbeatTimer = setInterval(() => this.onHeartbeat(), this.heartbeatIntervalMs); + this.heartbeatTimer.unref?.(); + } + opts.registry.add(this); + this.globalTarget = opts.globalFanout?.addTarget((msg) => this.send(msg)); + this.send({ + type: 'hello', + protocol_version: WS_V2_PROTOCOL_VERSION, + server_id: opts.serverId, + capabilities: [...WS_V2_CAPABILITIES], + } as ServerMessage); + } + + get hasClientHello(): boolean { + return true; + } + + get subscriptionSessionIds(): readonly string[] { + return [...new Set(this.subscriptions.map((subscription) => subscription.sessionId))]; + } + + close(code?: number, reason?: string): void { + this.teardown(); + this.opts.socket.close(code, reason); + } + + private onMessage(data: unknown): void { + if (this.closed || this.overflowed) return; + let raw: unknown; + try { + raw = JSON.parse(String(data)); + } catch { + this.sendError(ErrorCode.VALIDATION_FAILED, 'invalid JSON frame'); + return; + } + const parsed = clientMessageSchema.safeParse(raw); + if (!parsed.success) { + this.sendError(ErrorCode.VALIDATION_FAILED, 'invalid client frame'); + return; + } + const frame = parsed.data; + if (frame.type === 'subscribe') this.onSubscribe(frame.session_id, frame.agent_id, frame.omit, frame.id); + else this.onUnsubscribe(frame.session_id, frame.id); + } + + private onSubscribe(sessionId: string, agentId: string | undefined, omit: string[] | undefined, id: number): void { + const source = this.opts.sessionSourceFor(sessionId); + if (source === undefined) { + this.send({ type: 'ack', id, code: ErrorCode.SESSION_NOT_FOUND }); + return; + } + const binding = this.opts.binder.attach(source); + const targetAgentId = agentId ?? 'main'; + const mainChannel = agentId === undefined; + const subscription: Subscription = { + sessionId, + agentId: targetAgentId, + mainChannel, + omit: new Set(omit ?? []), + agentMessages: binding.agentFor(targetAgentId).onMessages((msgs) => this.sendMany(msgs, subscription.omit)), + }; + if (mainChannel) { + subscription.sessionMessages = binding.onSessionMessages((msgs) => this.sendMany(msgs, subscription.omit)); + } + this.subscriptions.push(subscription); + this.send({ type: 'ack', id, code: 0 }); + const recovery = binding.recoveryFor(targetAgentId); + this.sendMany(recovery, subscription.omit); + } + + private onUnsubscribe(sessionId: string, id: number): void { + for (const subscription of this.subscriptions.filter((candidate) => candidate.sessionId === sessionId)) { + subscription.agentMessages.dispose(); + subscription.sessionMessages?.dispose(); + } + this.subscriptions.splice(0, this.subscriptions.length, ...this.subscriptions.filter((c) => c.sessionId !== sessionId)); + this.send({ type: 'ack', id, code: 0 }); + } + + private sendMany(msgs: readonly ServerMessage[], omit: ReadonlySet): void { + for (const msg of msgs) { + if (omit.has(msg.type)) continue; + this.send(msg); + } + } + + private send(msg: ServerMessage): void { + if (this.closed || this.overflowed) return; + const parsed = serverMessageSchema.safeParse(msg); + if (!parsed.success) { + this.opts.logger?.warn({ type: msg.type }, 'ws2 outbound dropped: contract violation'); + return; + } + if (this.outboundQueue.length >= this.outboundCapacity) { + this.overflow(); + return; + } + this.outboundQueue.push(JSON.stringify(parsed.data)); + this.pump(); + } + + private sendError(code: number, msg: string): void { + this.send({ type: 'error', code, msg } as ServerMessage); + } + + private pump(): void { + while (this.inflight < this.inflightWindow && this.outboundQueue.length > 0) { + const data = this.outboundQueue.shift()!; + this.inflight += 1; + this.opts.socket.send(data, () => { + this.inflight -= 1; + this.pump(); + }); + } + } + + private overflow(): void { + if (this.overflowed) return; + this.overflowed = true; + const frame = JSON.stringify({ + type: 'error', + code: 'backpressure_overflow', + msg: BACKPRESSURE_OVERFLOW_MESSAGE, + }); + try { + this.opts.socket.send(frame, () => {}); + } catch { + } + this.teardown(); + this.opts.socket.close(); + } + + private onHeartbeat(): void { + if (this.closed) return; + if (this.clock() - this.lastPongAt >= this.heartbeatIntervalMs * HEARTBEAT_MISS_LIMIT) { + this.opts.socket.close(); + return; + } + this.opts.socket.ping(); + } + + private onClose(): void { + this.teardown(); + } + + private teardown(): void { + if (this.closed) return; + this.closed = true; + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + for (const subscription of this.subscriptions) { + subscription.agentMessages.dispose(); + subscription.sessionMessages?.dispose(); + } + this.subscriptions.length = 0; + this.globalTarget?.dispose(); + this.opts.registry.remove(this.id); + } +} diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 3a24e5db0c6..10fa1e96ef1 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -204,6 +204,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "GET", "/api/v1/sessions/{session_id}/goal", ], + [ + "GET", + "/api/v1/sessions/{session_id}/history", + ], [ "GET", "/api/v1/sessions/{session_id}/media/{file_id}", diff --git a/packages/kap-server/test/fixtures/v2-examples.json b/packages/kap-server/test/fixtures/v2-examples.json new file mode 100644 index 00000000000..a4f3311302d --- /dev/null +++ b/packages/kap-server/test/fixtures/v2-examples.json @@ -0,0 +1,3579 @@ +{ + "tabs": [ + { + "id": "basic", + "title": "最小流式对话", + "desc": "用户发「你好」,AI 思考后回答。无工具、无审批、无任务。会话 s_01 无历史,已连接订阅。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"你好\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\"\n}" + }, + { + "note": "turn 开始(`user_message_id` 盖在首批消息上)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_01\",\n \"timestamp\": \"2026-09-03T10:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788429600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考。实体消息先占位(`status: streaming`),delta 随后", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T10:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.420Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.420Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"用户在打招呼,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"简短回应即可。\"\n}" + }, + { + "note": "思考收敛,全量终值覆盖", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"用户在打招呼,简短回应即可。\"\n}" + }, + { + "note": "正文输出,同样的节奏", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"你好!\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"有什么可以帮你的?\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.400Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"你好!有什么可以帮你的?\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.400Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:00.022Z\",\n \"ended_at\": \"2026-09-03T10:00:01.400Z\",\n \"usage\": {\n \"input_other\": 1820,\n \"output\": 24,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.500Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.015Z\",\n \"ended_at\": \"2026-09-03T10:00:01.500Z\",\n \"usage\": {\n \"input_tokens\": 1820,\n \"output_tokens\": 24\n },\n \"duration_ms\": 1483,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.500Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"你好\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\",\n \"finished_at\": \"2026-09-03T10:00:01.500Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_01\",\n \"timestamp\": \"2026-09-03T10:00:01.520Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 1820,\n \"output\": 24,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1868,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 文本内容的节奏恒为 streaming 占位 → delta × N → completed 全量覆盖;delta 可丢,completed 是权威
· user_message_id 只盖在 turn 的首批消息上,客户端据此把回复绑定到发送
· 客户端对每条消息只有一条规则:按 id upsert(replace-by-id)" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "承接例 1。turn 完成后用户刷新页面,客户端经 REST 一次性拉取历史: GET /api/v1/sessions/s_01/history?page_size=50 响应(`200`,实体载荷与 WS 消息同型同 schema,按时间线顺序排列)", + "json": "{\n \"session_id\": \"s_01\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.500Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.012Z\",\n \"ended_at\": \"2026-09-03T10:00:01.495Z\",\n \"usage\": {\n \"input_tokens\": 1820,\n \"output_tokens\": 24\n },\n \"duration_ms\": 1483,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.400Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:00.020Z\",\n \"ended_at\": \"2026-09-03T10:00:01.395Z\",\n \"usage\": {\n \"input_other\": 1820,\n \"output\": 24,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"你好\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\",\n \"finished_at\": \"2026-09-03T10:00:01.500Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"用户在打招呼,简短回应即可。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_01\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.300Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"你好!有什么可以帮你的?\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + }, + { + "note": "断线重连时补尾巴而非全量拉:`GET /api/v1/sessions/s_01/history?after_step=t1.0`,本例无更新内容", + "json": "{\n \"session_id\": \"s_01\",\n \"items\": [],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_01/history?page_size=50", + "chip": "REST", + "tail": "要点
· 历史里只有落盘终态:delta、streaming 占位等过程消息不进历史(可丢消息不落盘)
· in_flight: null 表示会话空闲、无在流 step:本次恢复纯 REST 即完成,客户端连 WS 后只会收到一条 session.state(空闲快照)" + } + ] + }, + { + "id": "tool", + "title": "工具调用(ls)", + "desc": "用户发「执行一下 ls」,AI 思考后调 Bash 执行,拿到结果后输出正文。ls 为只读命令,免审批;快速命令无 tool.progress。会话 s_02 无历史,已连接订阅。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "起跑序列同例 1", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"执行一下 ls\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_02\",\n \"timestamp\": \"2026-09-03T11:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788433200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 2000,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:00.022Z\"\n}" + }, + { + "note": "思考(streaming → delta → completed)", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.420Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.420Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"用户想看当前目录内容,用 Bash 执行 ls。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.800Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"用户想看当前目录内容,用 Bash 执行 ls。\"\n}" + }, + { + "note": "过渡正文(streaming → delta → completed)", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.800Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.800Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"好的,执行 `ls`:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.000Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"好的,执行 `ls`:\"\n}" + }, + { + "note": "工具调用:参数流式生成 → 就绪执行 → 完成(免审批,直接执行)", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.000Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"{\\\"command\\\": \\\"ls\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.100Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"\\\"}\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.150Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"ls\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.600Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"ls\"\n },\n \"output\": {\n \"stdout\": \"apps\\ndocs\\npackages\\npnpm-workspace.yaml\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "step t1.0 收官(step = 一次 LLM 调用 + 其触发的工具执行,工具齐了才完成)", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.700Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:00.022Z\",\n \"ended_at\": \"2026-09-03T11:00:01.700Z\",\n \"usage\": {\n \"input_other\": 2100,\n \"output\": 96,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "第二个 LLM 调用(带着工具结果)开始,产出最终正文", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.900Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:01.900Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.250Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.250Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"当前目录下有 4 个条目:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.400Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"`apps`、`docs`、`packages` 和 `pnpm-workspace.yaml`。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.600Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"当前目录下有 4 个条目:`apps`、`docs`、`packages` 和 `pnpm-workspace.yaml`。\"\n}" + }, + { + "note": "收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.600Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:01.900Z\",\n \"ended_at\": \"2026-09-03T11:00:02.600Z\",\n \"usage\": {\n \"input_other\": 2240,\n \"output\": 58,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.700Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"ended_at\": \"2026-09-03T11:00:02.700Z\",\n \"usage\": {\n \"input_tokens\": 4340,\n \"output_tokens\": 154\n },\n \"duration_ms\": 2683,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.700Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"执行一下 ls\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\",\n \"finished_at\": \"2026-09-03T11:00:02.700Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_02\",\n \"timestamp\": \"2026-09-03T11:00:02.720Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 4340,\n \"output\": 154,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4494,\n \"max_context_tokens\": 262144\n}" + } + ] + }, + { + "label": "REST 历史", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_02/history?page_size=50`", + "json": "{\n \"session_id\": \"s_02\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.700Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.012Z\",\n \"ended_at\": \"2026-09-03T11:00:02.695Z\",\n \"usage\": {\n \"input_tokens\": 4340,\n \"output_tokens\": 154\n },\n \"duration_ms\": 2683,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.700Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:00.020Z\",\n \"ended_at\": \"2026-09-03T11:00:01.695Z\",\n \"usage\": {\n \"input_other\": 2100,\n \"output\": 96,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"执行一下 ls\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\",\n \"finished_at\": \"2026-09-03T11:00:02.700Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.600Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"用户想看当前目录内容,用 Bash 执行 ls。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.800Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"好的,执行 `ls`:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.600Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"ls\"\n },\n \"output\": {\n \"stdout\": \"apps\\ndocs\\npackages\\npnpm-workspace.yaml\\n\",\n \"exit_code\": 0\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.600Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:01.895Z\",\n \"ended_at\": \"2026-09-03T11:00:02.595Z\",\n \"usage\": {\n \"input_other\": 2240,\n \"output\": 58,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_02\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"当前目录下有 4 个条目:`apps`、`docs`、`packages` 和 `pnpm-workspace.yaml`。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_02/history?page_size=50", + "chip": "REST", + "tail": "要点
· 一次工具调用让一个 turn 产生两个 step:t1.0(LLM 调用 → 决定调工具 → 工具执行)与 t1.1(带工具结果再调 LLM → 产出正文)。step 是「一次 LLM 调用 + 其触发的工具执行」
· tool_call 的参数流式是 tool_call.delta(可丢);input 就绪与 state: done 都是完整实体消息。output 只在 done 时出现
· 免审批的调用没有 interaction;快速命令没有 tool.progress——这两类消息都是按需出现,不是生命周期必经环节
· REST 历史里 tool_call 为 done 终态(含完整 output),参数流式 delta 不落盘" + } + ] + }, + { + "id": "multi-tool", + "title": "多工具串联(Write/Edit/Bash)", + "desc": "用户发「写一个 hello.py 打印当前时间,加个 shebang,然后跑一下」。三个工具顺序执行(Write 创建 → Edit 补 shebang → Bash 运行),均免审批。会话 s_03 无历史,已连接订阅。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"写一个 hello.py 打印当前时间,加个 shebang,然后跑一下\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T12:00:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T12:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_03\",\n \"timestamp\": \"2026-09-03T12:00:00.018Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788436800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 2000,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T12:00:00.022Z\"\n}" + }, + { + "note": "思考(streaming → delta → completed)", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.420Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.420Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"先 Write 创建脚本,再 Edit 加 shebang,最后 Bash 运行。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.750Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先 Write 创建脚本,再 Edit 加 shebang,最后 Bash 运行。\"\n}" + }, + { + "note": "过渡正文(streaming → delta → completed)", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.750Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.750Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我来创建 `hello.py`:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.000Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来创建 `hello.py`:\"\n}" + }, + { + "note": "第一个工具 Write(参数流式 → 就绪 → 完成)", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.000Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"{\\\"path\\\": \\\"hello.py\\\", \\\"content\\\": \\\"from datetime import datetime\\\\nprint(datetime.now())\\\\n\\\"}\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.100Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Write\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"hello.py\",\n \"content\": \"from datetime import datetime\\nprint(datetime.now())\\n\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.400Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Write\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"hello.py\",\n \"content\": \"from datetime import datetime\\nprint(datetime.now())\\n\"\n },\n \"output\": {\n \"bytes_written\": 52\n }\n}" + }, + { + "note": "step t1.0 收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.500Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:00.022Z\",\n \"ended_at\": \"2026-09-03T12:00:01.500Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 130,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "step t1.1 开始,Edit 补 shebang", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.700Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T12:00:01.700Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.950Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.950Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"补上 shebang:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.100Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"补上 shebang:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.100Z\",\n \"tool_call_id\": \"call_02\",\n \"input_text\": \"{\\\"path\\\": \\\"hello.py\\\", \\\"old\\\": \\\"from datetime import datetime\\\", \\\"new\\\": \\\"#!/usr/bin/env python3\\\\nfrom datetime import datetime\\\"}\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.200Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"hello.py\",\n \"old\": \"from datetime import datetime\",\n \"new\": \"#!/usr/bin/env python3\\nfrom datetime import datetime\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.400Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"hello.py\",\n \"old\": \"from datetime import datetime\",\n \"new\": \"#!/usr/bin/env python3\\nfrom datetime import datetime\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.500Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:01.700Z\",\n \"ended_at\": \"2026-09-03T12:00:02.500Z\",\n \"usage\": {\n \"input_other\": 2580,\n \"output\": 74,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "step t1.2 开始,Bash 运行", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.700Z\",\n \"step_id\": \"t1.2\",\n \"turn_id\": \"t1\",\n \"ordinal\": 2,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T12:00:02.700Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.850Z\",\n \"message_id\": \"t1.2.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.2\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.850Z\",\n \"message_id\": \"t1.2.a0\",\n \"text\": \"跑一下验证:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.000Z\",\n \"message_id\": \"t1.2.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.2\",\n \"status\": \"completed\",\n \"text\": \"跑一下验证:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.000Z\",\n \"tool_call_id\": \"call_03\",\n \"input_text\": \"{\\\"command\\\": \\\"python3 hello.py\\\"}\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.100Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.2\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"python3 hello.py\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.600Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.2\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"python3 hello.py\"\n },\n \"output\": {\n \"stdout\": \"2026-09-03 12:00:03.587201\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.700Z\",\n \"step_id\": \"t1.2\",\n \"turn_id\": \"t1\",\n \"ordinal\": 2,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:02.700Z\",\n \"ended_at\": \"2026-09-03T12:00:03.700Z\",\n \"usage\": {\n \"input_other\": 2720,\n \"output\": 66,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "step t1.3 开始,产出最终正文", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.900Z\",\n \"step_id\": \"t1.3\",\n \"turn_id\": \"t1\",\n \"ordinal\": 3,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T12:00:03.900Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.100Z\",\n \"message_id\": \"t1.3.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.3\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.100Z\",\n \"message_id\": \"t1.3.a0\",\n \"text\": \"完成。`hello.py` 已创建并加上 shebang,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.150Z\",\n \"message_id\": \"t1.3.a0\",\n \"text\": \"运行输出当前时间,一切正常。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.300Z\",\n \"message_id\": \"t1.3.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.3\",\n \"status\": \"completed\",\n \"text\": \"完成。`hello.py` 已创建并加上 shebang,运行输出当前时间,一切正常。\"\n}" + }, + { + "note": "依次收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.300Z\",\n \"step_id\": \"t1.3\",\n \"turn_id\": \"t1\",\n \"ordinal\": 3,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:03.900Z\",\n \"ended_at\": \"2026-09-03T12:00:04.300Z\",\n \"usage\": {\n \"input_other\": 2830,\n \"output\": 62,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.400Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T12:00:00.015Z\",\n \"ended_at\": \"2026-09-03T12:00:04.400Z\",\n \"usage\": {\n \"input_tokens\": 10530,\n \"output_tokens\": 332\n },\n \"duration_ms\": 4383,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.400Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"写一个 hello.py 打印当前时间,加个 shebang,然后跑一下\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T12:00:00.000Z\",\n \"finished_at\": \"2026-09-03T12:00:04.400Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_03\",\n \"timestamp\": \"2026-09-03T12:00:04.420Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 10530,\n \"output\": 332,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 10862,\n \"max_context_tokens\": 262144\n}" + } + ] + }, + { + "label": "REST 历史", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_03/history?page_size=50`", + "json": "{\n \"session_id\": \"s_03\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.400Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T12:00:00.012Z\",\n \"ended_at\": \"2026-09-03T12:00:04.395Z\",\n \"usage\": {\n \"input_tokens\": 10530,\n \"output_tokens\": 332\n },\n \"duration_ms\": 4383,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.500Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:00.020Z\",\n \"ended_at\": \"2026-09-03T12:00:01.495Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 130,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"写一个 hello.py 打印当前时间,加个 shebang,然后跑一下\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T12:00:00.000Z\",\n \"finished_at\": \"2026-09-03T12:00:04.400Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.600Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先 Write 创建脚本,再 Edit 加 shebang,最后 Bash 运行。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.800Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来创建 `hello.py`:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.400Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Write\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"hello.py\",\n \"content\": \"from datetime import datetime\\nprint(datetime.now())\\n\"\n },\n \"output\": {\n \"bytes_written\": 52\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.500Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:01.695Z\",\n \"ended_at\": \"2026-09-03T12:00:02.495Z\",\n \"usage\": {\n \"input_other\": 2580,\n \"output\": 74,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.000Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"补上 shebang:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.400Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"hello.py\",\n \"old\": \"from datetime import datetime\",\n \"new\": \"#!/usr/bin/env python3\\nfrom datetime import datetime\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.700Z\",\n \"step_id\": \"t1.2\",\n \"turn_id\": \"t1\",\n \"ordinal\": 2,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:02.695Z\",\n \"ended_at\": \"2026-09-03T12:00:03.695Z\",\n \"usage\": {\n \"input_other\": 2720,\n \"output\": 66,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.900Z\",\n \"message_id\": \"t1.2.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.2\",\n \"status\": \"completed\",\n \"text\": \"跑一下验证:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.600Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.2\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"python3 hello.py\"\n },\n \"output\": {\n \"stdout\": \"2026-09-03 12:00:03.587201\\n\",\n \"exit_code\": 0\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.300Z\",\n \"step_id\": \"t1.3\",\n \"turn_id\": \"t1\",\n \"ordinal\": 3,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:03.895Z\",\n \"ended_at\": \"2026-09-03T12:00:04.295Z\",\n \"usage\": {\n \"input_other\": 2830,\n \"output\": 62,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_03\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:04.200Z\",\n \"message_id\": \"t1.3.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.3\",\n \"status\": \"completed\",\n \"text\": \"完成。`hello.py` 已创建并加上 shebang,运行输出当前时间,一切正常。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_03/history?page_size=50", + "chip": "REST", + "tail": "要点
· 多次工具调用 = 多个 step 串联(每个 step 一次 LLM 调用 + 其工具执行);有依赖关系的调用(Edit 依赖 Write 的结果)必然落在不同 step,不可能并行
· 每个 tool_call 的生命周期互相独立:call_01/call_02/call_03 各自的 running/done 按 id 互不影响
· 每个 step 的过渡正文与最终正文一样走 streaming → delta → completed 节律;参数流式 tool_call.delta 对每个工具调用同样存在
· REST 历史里全部为 completed/done 终态;session.state、prompt、delta 均不落盘" + } + ] + }, + { + "id": "recovery", + "title": "中途刷新与断线重连", + "desc": "演示恢复模型三句话:落盘的归 REST、在流的归回放、状态实体全量重发。会话 s_04:t1 已完成(10:30,用户问入口文件);t2 先读文件(t2.0 已完成)、回答的正文流到一半(t2.1 在流)——此刻用户刷新页面(A),或 WS 断线自动重连(B)。", + "sections": [ + { + "label": "刷新前的直播(WS)", + "items": [ + { + "note": "起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"这个 CLI 的入口文件是哪个?\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T10:30:00.000Z\"\n}" + }, + { + "note": "turn 开始(`user_message_id` 盖在首批消息上)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:30:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T10:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788431400015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考。实体消息先占位(`status: streaming`),delta 随后", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T10:30:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"入口是 src/cli.ts,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.600Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"顺带说明参数解析与子命令分发。\"\n}" + }, + { + "note": "思考收敛,全量终值覆盖", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"入口是 src/cli.ts,顺带说明参数解析与子命令分发。\"\n}" + }, + { + "note": "正文输出,同样的节奏", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"入口是 `src/cli.ts`:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.250Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"全局参数在这里解析,再分发到 `build`、`dev`、`test` 三个子命令。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"入口是 `src/cli.ts`:全局参数在这里解析,再分发到 `build`、`dev`、`test` 三个子命令。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.500Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:30:00.022Z\",\n \"ended_at\": \"2026-09-03T10:30:01.500Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 58,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.620Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:30:00.015Z\",\n \"ended_at\": \"2026-09-03T10:30:01.620Z\",\n \"usage\": {\n \"input_tokens\": 2400,\n \"output_tokens\": 58\n },\n \"duration_ms\": 1603,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.620Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"这个 CLI 的入口文件是哪个?\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:30:00.000Z\",\n \"finished_at\": \"2026-09-03T10:30:01.620Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T10:30:01.640Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2400,\n \"output\": 58,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 2460,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "11:00,用户提交第二轮 prompt,t2 起跑", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.010Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.015Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T11:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788433200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2400,\n \"output\": 58,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 2460,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.022Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:00.022Z\"\n}" + }, + { + "note": "第一个 step:思考后决定先读文件——", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.450Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.450Z\",\n \"message_id\": \"t2.0.h0\",\n \"text\": \"先读入口文件的参数解析部分,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.600Z\",\n \"message_id\": \"t2.0.h0\",\n \"text\": \"再给方案建议。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.050Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"先读入口文件的参数解析部分,再给方案建议。\"\n}" + }, + { + "note": "过渡正文,然后发起工具调用(参数流式增量可丢)", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.050Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.050Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"我看一下 `src/cli.ts` 的参数解析实现:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.300Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"我看一下 `src/cli.ts` 的参数解析实现:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.300Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"{\\\"path\\\": \\\"src/cli\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.400Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \".ts\\\"}\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"name\": \"Read\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"src/cli.ts\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.900Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"src/cli.ts\"\n },\n \"output\": {\n \"content\": \"…(文件内容,含 parseArgs 实现)…\",\n \"lines\": 86\n }\n}" + }, + { + "note": "t2.0 收官(`finish_reason: tool_use`——还有后续 step)", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.000Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:00.022Z\",\n \"ended_at\": \"2026-09-03T11:00:02.000Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 64,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "第二个 step:带工具结果再调 LLM,正文开始流出——", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.050Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:02.050Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.400Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.400Z\",\n \"message_id\": \"t2.1.a0\",\n \"text\": \"建议加在入口的\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.700Z\",\n \"message_id\": \"t2.1.a0\",\n \"text\": \"全局参数解析处:\"\n}" + } + ], + "tail": "要点
· t1 与 t2.0 均已收官(落盘),t2 的 user 消息创建即落盘,t2.1 的 assistant 流到一半(未落盘)。此刻用户刷新页面——以上一切在客户端内存中清空:t1、t2 的 user 消息与 t2.0 归 REST 历史,t2.1 归 WS 回放" + }, + { + "label": "A · 刷新:REST 历史", + "items": [ + { + "note": "页面刷新,内存全空——先拉全量历史: GET /api/v1/sessions/s_04/history?page_size=50 响应(`200`。t2 的第一个 step 已落盘、第二个仍在流:历史含 t1 全部、t2 的 user 消息与 t2.0 的终态,`in_flight` 标记在流位置)", + "json": "{\n \"session_id\": \"s_04\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.620Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:30:00.012Z\",\n \"ended_at\": \"2026-09-03T10:30:01.615Z\",\n \"usage\": {\n \"input_tokens\": 2400,\n \"output_tokens\": 58\n },\n \"duration_ms\": 1603,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.500Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:30:00.020Z\",\n \"ended_at\": \"2026-09-03T10:30:01.495Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 58,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"这个 CLI 的入口文件是哪个?\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:30:00.000Z\",\n \"finished_at\": \"2026-09-03T10:30:01.615Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:00.800Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"入口是 src/cli.ts,顺带说明参数解析与子命令分发。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:30:01.400Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"入口是 `src/cli.ts`:全局参数在这里解析,再分发到 `build`、`dev`、`test` 三个子命令。\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:02.000Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:00.020Z\",\n \"ended_at\": \"2026-09-03T11:00:01.995Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 64,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.010Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.700Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"先读入口文件的参数解析部分,再给方案建议。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.200Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"我看一下 `src/cli.ts` 的参数解析实现:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.900Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"src/cli.ts\"\n },\n \"output\": {\n \"content\": \"…(文件内容,含 parseArgs 实现)…\",\n \"lines\": 86\n }\n }\n ],\n \"has_more\": false,\n \"in_flight\": {\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\"\n }\n}" + } + ], + "request": "GET /api/v1/sessions/s_04/history?page_size=50", + "chip": "REST", + "tail": "要点
· 落盘的归 REST:items 含 t1 全部 + t2 的 user 消息(创建即落盘)+ 当前 turn 里已完成 step(t2.0)的终态实体;在流的 t2.1 一条都没有——未落盘不进历史
· t2 的 turn 封面不落盘(turn 未完成),经 WS 回放下发
· in_flight 非 null = 「还有戏」:客户端据此知道要等 WS 恢复载荷,t2.1 区域先渲染骨架" + }, + { + "label": "A · 刷新:WS 恢复与直播", + "items": [ + { + "note": "WS 重连,握手即恢复(无协商、无游标)", + "json": "{\n \"type\": \"hello\",\n \"protocol_version\": 2,\n \"server_id\": \"srv_9f2c\",\n \"capabilities\": [\n \"step_replay_v1\",\n \"interaction_v1\"\n ]\n}" + }, + { + "note": "客户端订阅(C→S)", + "json": "{\n \"type\": \"subscribe\",\n \"id\": 1,\n \"session_id\": \"s_04\"\n}" + }, + { + "note": "确认(S→C)", + "json": "{\n \"type\": \"ack\",\n \"id\": 1,\n \"code\": 0\n}" + }, + { + "note": "恢复载荷——turn 封面 + 在流 step t2.1 的内容。注意没有 t2.0 的任何内容(已落盘,在 REST 里)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:03.630Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:03.631Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:02.050Z\"\n}" + }, + { + "note": "assistant 还在流:`status: streaming` + 断点时刻的累积全量", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:03.632Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"streaming\",\n \"text\": \"建议加在入口的全局参数解析处:\"\n}" + }, + { + "note": "状态实体全量重发(本例只有 session.state)", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T11:00:03.633Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 1,\n \"step_id\": \"t2.1\",\n \"since\": 1788433200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 5700,\n \"output\": 150,\n \"input_cache_read\": 20000,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 8100,\n \"output\": 208,\n \"input_cache_read\": 20000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 8100,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "恢复完成,后续全是直播", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:04.100Z\",\n \"message_id\": \"t2.1.a0\",\n \"text\": \"`src/cli.ts` 的 `parseArgs` 里注册 `--verbose` 全局选项,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:04.600Z\",\n \"message_id\": \"t2.1.a0\",\n \"text\": \"子命令自动继承;日志模块读到该标志后调到 debug 级别。\"\n}" + }, + { + "note": "completed 权威覆盖", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.100Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"completed\",\n \"text\": \"建议加在入口的全局参数解析处:`src/cli.ts` 的 `parseArgs` 里注册 `--verbose` 全局选项,子命令自动继承;日志模块读到该标志后调到 debug 级别。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.100Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:02.050Z\",\n \"ended_at\": \"2026-09-03T11:00:05.100Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 96,\n \"input_cache_read\": 12800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.200Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"ended_at\": \"2026-09-03T11:00:05.200Z\",\n \"usage\": {\n \"input_tokens\": 5900,\n \"output_tokens\": 160\n },\n \"duration_ms\": 5183,\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "user 消息随 turn 收官(补漏写帧)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.200Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\",\n \"finished_at\": \"2026-09-03T11:00:05.200Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T11:00:05.220Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 8300,\n \"output\": 218,\n \"input_cache_read\": 20800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 8500,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 恢复载荷 = turn 封面 + 在流 step t2.1 的内容 + 状态实体;t2.0 的内容一条都没有——已完成的 step 落盘归 REST,不重发。回放的粒度是 step 不是 turn
· turn 记录只是封面(user_message_id、origin、状态);t2 的用户消息创建即落盘,随 REST 历史到达,不经回放
· 在流 assistant 以 status: streaming + 累积全量重发(断点快照);随后 delta 照常追加,completed 权威覆盖。都是普通实体消息,无 replay 标记,客户端按 id upsert
· 恢复与直播在同一条会话序列里排队下发(§8);恢复载荷的 timestamp 是重发时刻,started_at 等实体内部时间保留原值
· 状态实体全量重发:本例只有 session.state;pending interaction / todo / 排队中的消息(running、尚无 turn)存在时一并重发(见例 6/7/8)" + }, + { + "label": "B · 重连:REST 补尾巴", + "items": [ + { + "note": "把时钟拨回 delta2 之后:这次不是刷新,而是 WS 断线。A、B 是同一断点的两种假想走向,时间线各自独立(实体 id 相同、时刻不同)。客户端本地有 t1 全部、t2.0 终态与 t2.1 半截(assistant 只累积到「建议加在入口的全局参数解析处:」);断线期间模型继续流,delta3 / delta4 照常发出,客户端没收到。几经退避,自动重连在 3 秒后成功。 重连前先补尾巴(游标 = 本地最后一个已完成 step): GET /api/v1/sessions/s_04/history?after_step=t2.0 响应(`200`,断线期间没有新落盘内容)", + "json": "{\n \"session_id\": \"s_04\",\n \"items\": [],\n \"has_more\": false,\n \"in_flight\": {\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\"\n }\n}" + } + ], + "request": "GET /api/v1/sessions/s_04/history?after_step=t2.0", + "chip": "REST", + "tail": "要点
· 补尾巴用 after_step 游标,一页拿完;空 items + 非空 in_flight = 没错过落盘内容,但有在流内容要等 WS 回放" + }, + { + "label": "B · 重连:WS 恢复与直播", + "items": [ + { + "note": "握手与 A 逐字节相同", + "json": "{\n \"type\": \"hello\",\n \"protocol_version\": 2,\n \"server_id\": \"srv_9f2c\",\n \"capabilities\": [\n \"step_replay_v1\",\n \"interaction_v1\"\n ]\n}" + }, + { + "note": "客户端订阅(C→S)", + "json": "{\n \"type\": \"subscribe\",\n \"id\": 1,\n \"session_id\": \"s_04\"\n}" + }, + { + "note": "确认(S→C)", + "json": "{\n \"type\": \"ack\",\n \"id\": 1,\n \"code\": 0\n}" + }, + { + "note": "恢复载荷。重发时刻更晚,断线期间的 delta3 / delta4 已计入服务端累积", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.610Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.611Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:02.050Z\"\n}" + }, + { + "note": "assistant 仍是 `streaming`,但累积全量已包含断线期间的 delta——比 A 的断点快照更长", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.612Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"streaming\",\n \"text\": \"建议加在入口的全局参数解析处:`src/cli.ts` 的 `parseArgs` 里注册 `--verbose` 全局选项,子命令自动继承;日志模块读到该标志后调到 debug 级别。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T11:00:05.613Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 1,\n \"step_id\": \"t2.1\",\n \"since\": 1788433200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 5850,\n \"output\": 156,\n \"input_cache_read\": 20600,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 8250,\n \"output\": 214,\n \"input_cache_read\": 20600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 8300,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "本地半截 assistant 被按 id 整体覆盖;直播只剩收官", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.400Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"completed\",\n \"text\": \"建议加在入口的全局参数解析处:`src/cli.ts` 的 `parseArgs` 里注册 `--verbose` 全局选项,子命令自动继承;日志模块读到该标志后调到 debug 级别。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.400Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:02.050Z\",\n \"ended_at\": \"2026-09-03T11:00:05.100Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 96,\n \"input_cache_read\": 12800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.500Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"ended_at\": \"2026-09-03T11:00:05.200Z\",\n \"usage\": {\n \"input_tokens\": 5900,\n \"output_tokens\": 160\n },\n \"duration_ms\": 6483,\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "user 消息随 turn 收官(补漏写帧)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_04\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.500Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\",\n \"finished_at\": \"2026-09-03T11:00:05.200Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_04\",\n \"timestamp\": \"2026-09-03T11:00:06.520Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 8300,\n \"output\": 218,\n \"input_cache_read\": 20800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 8500,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 断线重连与页面刷新的服务端动作一字不差(§9.3);唯一差别在 REST:补尾巴用 after_step(这里是 t2.0)而非全量
· 本地半截实体被权威版本按 id 整体覆盖——不 diff、不合并
· 断线期间丢失的 delta3 / delta4 无人想念:恢复载荷的累积全量已包含(§9.4)
· 若断线期间 t2.1 已完成:恢复载荷里没有它,completed 实体出现在 REST 尾巴里,闭环", + "branch_from": "直播" + } + ] + }, + { + "id": "approval", + "title": "审批(允许 / 拒绝)", + "desc": "Bash 跑脚本需要审批:tool_call 挂起、interaction 弹出。A 线用户允许(工具执行),B 线用户拒绝(工具不执行,对话继续)。会话 s_05。", + "sections": [ + { + "label": "起跑与审批请求(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把登录页崩溃的复现脚本跑一下\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\"\n}" + }, + { + "note": "turn 开始(`user_message_id` 盖在首批消息上)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_05\",\n \"timestamp\": \"2026-09-03T10:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788429600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T10:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"复现脚本在 `scripts/` 下,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"node 执行需要审批。\"\n}" + }, + { + "note": "思考收敛,全量终值覆盖", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"复现脚本在 `scripts/` 下,node 执行需要审批。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我来跑一下复现脚本:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.300Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑一下复现脚本:\"\n}" + }, + { + "note": "参数流式(可丢增量)", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.300Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"{\\\"command\\\": \\\"node scripts/repro-login-crash.mjs\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.400Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"\\\"}\"\n}" + }, + { + "note": "参数就绪,工具进入待批状态", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n }\n}" + }, + { + "note": "审批请求挂出——审批框弹出", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.510Z\",\n \"interaction_id\": \"ap_01\",\n \"kind\": \"approval\",\n \"state\": \"pending\",\n \"tool_call_id\": \"call_01\",\n \"request\": {\n \"tool_name\": \"Bash\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"reason\": \"运行脚本需要执行权限\"\n }\n}" + }, + { + "note": "`approval_id` 回链到工具卡——客户端把审批态合到工具卡上", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.510Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"approval_id\": \"ap_01\"\n}" + }, + { + "note": "聚合快照:`pending_interaction` 与 phase 同步切换", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_05\",\n \"timestamp\": \"2026-09-03T10:00:01.520Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"approval\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"awaiting_approval\",\n \"turn_id\": 0,\n \"step\": 0,\n \"since\": 1788429601510\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4280,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· tool_callapproval_id)与 interactiontool_call_id)是两条独立实体消息,互相用 id 关联——审批框和时间线上的工具卡片各自渲染
· 待批期间 turn / step 仍 running、busy 仍为 true:session.statepending_interaction: approvalphase.kind: awaiting_approval 标出「在等人」" + }, + { + "label": "A · 批准(WS)", + "items": [ + { + "note": "用户点「允许」(REST `POST /sessions/s_05/approvals/ap_01:decide`,不在 WS)。WS 上——interaction 到终态,`request` 原样保留、`response` 携带决策", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.100Z\",\n \"interaction_id\": \"ap_01\",\n \"kind\": \"approval\",\n \"state\": \"approved\",\n \"tool_call_id\": \"call_01\",\n \"request\": {\n \"tool_name\": \"Bash\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"reason\": \"运行脚本需要执行权限\"\n },\n \"response\": {\n \"decision\": \"approved\"\n }\n}" + }, + { + "note": "聚合快照回到运行态", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_05\",\n \"timestamp\": \"2026-09-03T10:00:05.110Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788429600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4280,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "工具开始执行;长输出经 `tool.progress` 反馈(可丢)", + "json": "{\n \"type\": \"tool.progress\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.900Z\",\n \"tool_call_id\": \"call_01\",\n \"progress\": {\n \"kind\": \"stdout\",\n \"text\": \"TypeError: Cannot read properties of undefined (reading 'token')\"\n }\n}" + }, + { + "note": "执行完成,`output` 只在 done 时出现", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.200Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"output\": {\n \"stdout\": \"TypeError: Cannot read properties of undefined (reading 'token')\\n at handleLogin (LoginView.vue:87)\\n\",\n \"exit_code\": 1\n },\n \"approval_id\": \"ap_01\"\n}" + }, + { + "note": "step t1.0 收官(还有后续 step)", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.300Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:00.022Z\",\n \"ended_at\": \"2026-09-03T10:00:06.300Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带工具结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.350Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T10:00:06.350Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"复现成功,报错和浏览器里看到的一致:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.900Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"`handleLogin` 读取了 undefined 的 `token` 字段(`LoginView.vue:87`)。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.200Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"复现成功,报错和浏览器里看到的一致:`handleLogin` 读取了 undefined 的 `token` 字段(`LoginView.vue:87`)。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.200Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:06.350Z\",\n \"ended_at\": \"2026-09-03T10:00:07.200Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 88,\n \"input_cache_read\": 12800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.015Z\",\n \"ended_at\": \"2026-09-03T10:00:07.300Z\",\n \"usage\": {\n \"input_tokens\": 5500,\n \"output_tokens\": 140\n },\n \"duration_ms\": 7283,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.300Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\",\n \"finished_at\": \"2026-09-03T10:00:07.300Z\",\n \"text\": \"把登录页崩溃的复现脚本跑一下\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_05\",\n \"timestamp\": \"2026-09-03T10:00:07.320Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5500,\n \"output\": 140,\n \"input_cache_read\": 20800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5700,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 批准只改 interaction 的 state 并附 responserequest 在终态消息里原样保留——客户端无需缓存 pending 版本
· 批准后工具才真正执行:running(待批)→ done(有 output);待批期间的 tool_call 没有进度、没有输出", + "branch_from": "起跑" + }, + { + "label": "B · 拒绝(WS)", + "items": [ + { + "note": "同一断点的另一走向:用户点「拒绝」(REST,不在 WS)。interaction 到 rejected 终态", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.100Z\",\n \"interaction_id\": \"ap_01\",\n \"kind\": \"approval\",\n \"state\": \"rejected\",\n \"tool_call_id\": \"call_01\",\n \"request\": {\n \"tool_name\": \"Bash\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"reason\": \"运行脚本需要执行权限\"\n },\n \"response\": {\n \"decision\": \"rejected\"\n }\n}" + }, + { + "note": "聚合快照回到运行态", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_05\",\n \"timestamp\": \"2026-09-03T10:00:05.110Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788429600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4280,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "工具未执行,以 `error` 收官(无 `output`)", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.200Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"error\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"error\": \"rejected by user\",\n \"approval_id\": \"ap_01\"\n}" + }, + { + "note": "「被拒」就是本次工具结果,step 照常收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.300Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:00.022Z\",\n \"ended_at\": \"2026-09-03T10:00:05.300Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带「被拒」结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.350Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T10:00:05.350Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"好,那不跑了。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.900Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"需要我换个方式排查吗——比如直接读 `handleLogin` 的实现?\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.200Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"好,那不跑了。需要我换个方式排查吗——比如直接读 `handleLogin` 的实现?\"\n}" + }, + { + "note": "收官序列同 A", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.200Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:05.350Z\",\n \"ended_at\": \"2026-09-03T10:00:06.200Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 76,\n \"input_cache_read\": 12100,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.015Z\",\n \"ended_at\": \"2026-09-03T10:00:06.300Z\",\n \"usage\": {\n \"input_tokens\": 5300,\n \"output_tokens\": 128\n },\n \"duration_ms\": 6283,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.300Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\",\n \"finished_at\": \"2026-09-03T10:00:06.300Z\",\n \"text\": \"把登录页崩溃的复现脚本跑一下\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_05\",\n \"timestamp\": \"2026-09-03T10:00:06.320Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5300,\n \"output\": 128,\n \"input_cache_read\": 20100,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5500,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 拒绝也是终态:rejected + response;tool_call 以 error: \"rejected by user\" 收官、无 output
· agent 把「被拒」当普通工具结果继续——对话不断流,也不需要任何特殊消息", + "branch_from": "起跑" + }, + { + "label": "REST 历史(A)", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_05/history?page_size=50`", + "json": "{\n \"session_id\": \"s_05\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T10:00:00.012Z\",\n \"ended_at\": \"2026-09-03T10:00:07.295Z\",\n \"usage\": {\n \"input_tokens\": 5500,\n \"output_tokens\": 140\n },\n \"duration_ms\": 7283,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.300Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:00.020Z\",\n \"ended_at\": \"2026-09-03T10:00:06.295Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 52,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把登录页崩溃的复现脚本跑一下\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T10:00:00.000Z\",\n \"finished_at\": \"2026-09-03T10:00:07.295Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"复现脚本在 `scripts/` 下,node 执行需要审批。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑一下复现脚本:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:06.200Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"output\": {\n \"stdout\": \"TypeError: Cannot read properties of undefined (reading 'token')\\n at handleLogin (LoginView.vue:87)\\n\",\n \"exit_code\": 1\n },\n \"approval_id\": \"ap_01\"\n },\n {\n \"type\": \"interaction\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:05.100Z\",\n \"interaction_id\": \"ap_01\",\n \"kind\": \"approval\",\n \"state\": \"approved\",\n \"tool_call_id\": \"call_01\",\n \"request\": {\n \"tool_name\": \"Bash\",\n \"input\": {\n \"command\": \"node scripts/repro-login-crash.mjs\"\n },\n \"reason\": \"运行脚本需要执行权限\"\n },\n \"response\": {\n \"decision\": \"approved\"\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.200Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T10:00:06.345Z\",\n \"ended_at\": \"2026-09-03T10:00:07.195Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 88,\n \"input_cache_read\": 12800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_05\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T10:00:07.100Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"复现成功,报错和浏览器里看到的一致:`handleLogin` 读取了 undefined 的 `token` 字段(`LoginView.vue:87`)。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_05/history?page_size=50", + "chip": "REST", + "tail": "要点
· interaction 的**终态**落盘:审批痕迹(谁批的、批的什么)留在历史里可回看;pending 状态不进历史
· delta、tool.progressstreaming 占位等过程消息一律不落盘" + } + ] + }, + { + "id": "queue-abort", + "title": "排队与中断", + "desc": "t1 跑测试时用户追发一条(queued 排队),t1 完成后接棒为 t2;t2 正文流到一半,用户点停止。会话 s_06。", + "sections": [ + { + "label": "排队与接棒(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下测试套件\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_06\",\n \"timestamp\": \"2026-09-03T11:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788433200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"测试命令免审批,直接跑。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"测试命令免审批,直接跑。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我来跑测试:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.300Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑测试:\"\n}" + }, + { + "note": "参数流式", + "json": "{\n \"type\": \"tool_call.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.300Z\",\n \"tool_call_id\": \"call_01\",\n \"input_text\": \"{\\\"command\\\": \\\"pnpm test\\\"}\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test\"\n }\n}" + }, + { + "note": "测试要跑一会儿,进度经 `tool.progress` 反馈(可丢)", + "json": "{\n \"type\": \"tool.progress\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:03.000Z\",\n \"tool_call_id\": \"call_01\",\n \"progress\": {\n \"kind\": \"stdout\",\n \"text\": \"… 12 passed …\"\n }\n}" + }, + { + "note": "用户在等待时追发第二条——`status: running` 立即可见(尚无 turn,即排队中);排队中的用户消息也立刻进时间线(不等接棒)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:04.010Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"把失败的用例列出来\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T11:00:04.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool.progress\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:05.500Z\",\n \"tool_call_id\": \"call_01\",\n \"progress\": {\n \"kind\": \"stdout\",\n \"text\": \"… 2 failed …\"\n }\n}" + }, + { + "note": "测试跑完", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.000Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 failed | 3 passed (4)\\n Tests 2 failed | 18 passed (20)\\n\",\n \"exit_code\": 1\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:00.022Z\",\n \"ended_at\": \"2026-09-03T11:00:06.100Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 60,\n \"input_cache_read\": 9000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带工具结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.150Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:06.150Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"测试跑完了:18 通过、2 失败。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"测试跑完了:18 通过、2 失败。\"\n}" + }, + { + "note": "t1 收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.800Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:06.150Z\",\n \"ended_at\": \"2026-09-03T11:00:06.800Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 40,\n \"input_cache_read\": 11000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.900Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.015Z\",\n \"ended_at\": \"2026-09-03T11:00:06.900Z\",\n \"usage\": {\n \"input_tokens\": 5400,\n \"output_tokens\": 100\n },\n \"duration_ms\": 6883,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.900Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下测试套件\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\",\n \"finished_at\": \"2026-09-03T11:00:06.900Z\"\n}" + }, + { + "note": "排队的 t2.u0 立即接棒——无间隙,session.state 不回 idle", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.920Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"把失败的用例列出来\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T11:00:04.000Z\"\n}" + }, + { + "note": "t2 的 turn 此刻才创建", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.930Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:06.930Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_06\",\n \"timestamp\": \"2026-09-03T11:00:06.940Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788433206930\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5400,\n \"output\": 100,\n \"input_cache_read\": 20000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4400,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.950Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T11:00:06.950Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.250Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.250Z\",\n \"message_id\": \"t2.0.h0\",\n \"text\": \"从刚才的输出里挑失败用例即可,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.400Z\",\n \"message_id\": \"t2.0.h0\",\n \"text\": \"不用重跑。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.900Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"从刚才的输出里挑失败用例即可,不用重跑。\"\n}" + }, + { + "note": "正文开始流出", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.900Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.900Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"失败的两个用例都在 `auth` 目录下:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.100Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"`login.spec.ts` 的「过期 token 应跳转登录页」、\"\n}" + } + ], + "tail": "要点
· 排队的消息立刻可见:user 消息创建即进时间线(status: running 但尚无 turn——排队即此形态);turn 要等接棒才创建(turn_id 此刻才存在)
· 接棒无间隙:t1.u0 completedt2.u0 runningturn t2 running;session.state 全程 busy,不回 idle
· created_at 是用户发送时刻——排队时长(≈2.9s)可由此算出" + }, + { + "label": "中断(WS)", + "items": [ + { + "note": "用户点停止(REST `POST /sessions/s_06:abort { \"message_id\": \"t2.u0\" }`,不在 WS)。在流的 assistant 先以累积全量收官——completed 是权威,半截文本落盘", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.610Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"失败的两个用例都在 `auth` 目录下:`login.spec.ts` 的「过期 token 应跳转登录页」、\"\n}" + }, + { + "note": "step 以 `interrupted` 终态收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.610Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"interrupted\",\n \"started_at\": \"2026-09-03T11:00:06.950Z\",\n \"ended_at\": \"2026-09-03T11:00:08.610Z\",\n \"end_reason\": \"aborted by user\"\n}" + }, + { + "note": "turn 以 `cancelled` 终态收官", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.620Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:06.930Z\",\n \"ended_at\": \"2026-09-03T11:00:08.620Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "user 消息以 `completed` 终态收官(取消痕迹在 step / system 上)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.620Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:04.000Z\",\n \"finished_at\": \"2026-09-03T11:00:08.620Z\",\n \"text\": \"把失败的用例列出来\"\n}" + }, + { + "note": "会话回到空闲(activity 先于 prompt.aborted 结算)", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_06\",\n \"timestamp\": \"2026-09-03T11:00:08.640Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 7600,\n \"output\": 135,\n \"input_cache_read\": 26000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 6500,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "会话回到空闲(activity 先于 prompt.aborted 结算)", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.650Z\",\n \"payload\": {\n \"reason\": \"aborted\",\n \"turn_id\": \"t2\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"interruption\"\n}" + } + ], + "tail": "要点
· 中断 = 两处终态 + 一条系统标注:step: interrupted / turn: completed / user: completed + system(interruption)(取消原因全在它身上)
· 内容类消息无论正常完成还是中断,都以 completed 收官(权威终态)——被中断的 assistant 半截文本落盘,刷新后仍在
· abort 操作针对 message_id:运行中的发送被 abort 时,其 turn / step 一并收官" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "中断后刷新页面,`GET /api/v1/sessions/s_06/history?page_size=50`", + "json": "{\n \"session_id\": \"s_06\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.900Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:00.012Z\",\n \"ended_at\": \"2026-09-03T11:00:06.895Z\",\n \"usage\": {\n \"input_tokens\": 5400,\n \"output_tokens\": 100\n },\n \"duration_ms\": 6883,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:00.020Z\",\n \"ended_at\": \"2026-09-03T11:00:06.095Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 60,\n \"input_cache_read\": 9000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下测试套件\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:00.000Z\",\n \"finished_at\": \"2026-09-03T11:00:06.895Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"测试命令免审批,直接跑。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑测试:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.000Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 failed | 3 passed (4)\\n Tests 2 failed | 18 passed (20)\\n\",\n \"exit_code\": 1\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.800Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T11:00:06.145Z\",\n \"ended_at\": \"2026-09-03T11:00:06.795Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 40,\n \"input_cache_read\": 11000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:06.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"测试跑完了:18 通过、2 失败。\"\n },\n {\n \"type\": \"turn\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.620Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T11:00:06.928Z\",\n \"ended_at\": \"2026-09-03T11:00:08.615Z\",\n \"user_message_id\": \"t2.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.610Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"interrupted\",\n \"started_at\": \"2026-09-03T11:00:06.948Z\",\n \"ended_at\": \"2026-09-03T11:00:08.605Z\",\n \"end_reason\": \"aborted by user\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:04.010Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"把失败的用例列出来\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T11:00:04.000Z\",\n \"finished_at\": \"2026-09-03T11:00:08.615Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:07.550Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"从刚才的输出里挑失败用例即可,不用重跑。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.600Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"失败的两个用例都在 `auth` 目录下:`login.spec.ts` 的「过期 token 应跳转登录页」、\"\n },\n {\n \"type\": \"system\",\n \"session_id\": \"s_06\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T11:00:08.640Z\",\n \"payload\": {\n \"reason\": \"aborted\",\n \"turn_id\": \"t2\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"interruption\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_06/history?page_size=50", + "chip": "REST", + "tail": "要点
· 中断痕迹全部落盘:step: interruptedsystem(interruption)、半截 assistant(completed)——turn 只到 completed,取消由 system 表达
· user 消息以 completed 终态落盘(含被取消的——取消痕迹在 turn / system 上);running 是过程状态" + } + ] + }, + { + "id": "todo", + "title": "todo 生命周期", + "desc": "修白屏的三步任务:agent 用 TodoWrite 工具建清单,随进度 4 次全量更新(pending → in_progress → done)。会话 s_07。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"登录页点登录直接白屏,修一下\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T12:00:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T12:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_07\",\n \"timestamp\": \"2026-09-03T12:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788436800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T12:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"白屏一般是运行时错误。分三步:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"定位、修复、验证。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"白屏一般是运行时错误。分三步:定位、修复、验证。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我分三步处理:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.300Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我分三步处理:\"\n}" + }, + { + "note": "agent 调 `TodoWrite` 建清单——tool_call 是动作,`todo` 实体是随之更新的状态", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.300Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"TodoWrite\",\n \"state\": \"running\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"pending\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ]\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.350Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"pending\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "todo 全量实体(replace-by-id,客户端不 merge)", + "json": "{\n \"type\": \"todo\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.360Z\",\n \"todo_id\": \"td_01\",\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"pending\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ],\n \"updated_at\": \"2026-09-03T12:00:01.350Z\"\n}" + }, + { + "note": "先读代码", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.500Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Read\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.200Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\"\n },\n \"output\": {\n \"content\": \"\",\n \"lines\": 214\n }\n}" + }, + { + "note": "定位完成,再调 TodoWrite", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.300Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"TodoWrite\",\n \"state\": \"running\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ]\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.350Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"todo\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.360Z\",\n \"todo_id\": \"td_01\",\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ],\n \"updated_at\": \"2026-09-03T12:00:02.350Z\"\n}" + }, + { + "note": "修复", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.500Z\",\n \"tool_call_id\": \"call_04\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.000Z\",\n \"tool_call_id\": \"call_04\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:00.022Z\",\n \"ended_at\": \"2026-09-03T12:00:03.100Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 78,\n \"input_cache_read\": 10000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带工具结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.150Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T12:00:03.150Z\"\n}" + }, + { + "note": "修复完成,进入验证", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.300Z\",\n \"tool_call_id\": \"call_05\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"TodoWrite\",\n \"state\": \"running\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"in_progress\"\n }\n ]\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.350Z\",\n \"tool_call_id\": \"call_05\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"in_progress\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"todo\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.360Z\",\n \"todo_id\": \"td_01\",\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"in_progress\"\n }\n ],\n \"updated_at\": \"2026-09-03T12:00:03.350Z\"\n}" + }, + { + "note": "跑登录相关测试(免审批)", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.500Z\",\n \"tool_call_id\": \"call_06\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:05.500Z\",\n \"tool_call_id\": \"call_06\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 passed (1)\\n Tests 6 passed (6)\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "全部完成,清单最终版", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:05.600Z\",\n \"tool_call_id\": \"call_07\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"TodoWrite\",\n \"state\": \"running\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"done\"\n }\n ]\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:05.650Z\",\n \"tool_call_id\": \"call_07\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"done\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"todo\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:05.660Z\",\n \"todo_id\": \"td_01\",\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"done\"\n }\n ],\n \"updated_at\": \"2026-09-03T12:00:05.650Z\"\n}" + }, + { + "note": "总结正文,同样的 streaming → delta → completed 节律", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.000Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.000Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"修好了:`handleLogin` 在 `user` 为空时直接读 `token` 导致白屏,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.300Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"已加可选链判空;登录相关 6 个测试全部通过。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.600Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"修好了:`handleLogin` 在 `user` 为空时直接读 `token` 导致白屏,已加可选链判空;登录相关 6 个测试全部通过。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.600Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:03.150Z\",\n \"ended_at\": \"2026-09-03T12:00:06.600Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 84,\n \"input_cache_read\": 9500,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.700Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T12:00:00.015Z\",\n \"ended_at\": \"2026-09-03T12:00:06.700Z\",\n \"usage\": {\n \"input_tokens\": 5500,\n \"output_tokens\": 162\n },\n \"duration_ms\": 6683,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.700Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"登录页点登录直接白屏,修一下\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T12:00:00.000Z\",\n \"finished_at\": \"2026-09-03T12:00:06.700Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_07\",\n \"timestamp\": \"2026-09-03T12:00:06.720Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5500,\n \"output\": 162,\n \"input_cache_read\": 19500,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5700,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· todo 的每次变更由 TodoWrite tool_call 驱动:tool_call 是动作(时间线卡片),todo 是状态实体(面板、§9 恢复重发)——todo_id 互相关联
· todo 实体恒为**全量**(replace-by-id),客户端从不 merge 单项;updated_at 是清单内容最后变化时刻
· todo 与 step 生命周期解耦:td_01 贯穿整个 turn,更新可以出现在任何 step 内" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_07/history?page_size=50`", + "json": "{\n \"session_id\": \"s_07\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.700Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T12:00:00.012Z\",\n \"ended_at\": \"2026-09-03T12:00:06.695Z\",\n \"usage\": {\n \"input_tokens\": 5500,\n \"output_tokens\": 162\n },\n \"duration_ms\": 6683,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:00.020Z\",\n \"ended_at\": \"2026-09-03T12:00:03.095Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 78,\n \"input_cache_read\": 10000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"登录页点登录直接白屏,修一下\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T12:00:00.000Z\",\n \"finished_at\": \"2026-09-03T12:00:06.695Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"白屏一般是运行时错误。分三步:定位、修复、验证。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我分三步处理:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:01.350Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"pending\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.200Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\"\n },\n \"output\": {\n \"content\": \"\",\n \"lines\": 214\n }\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:02.350Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"in_progress\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"pending\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.000Z\",\n \"tool_call_id\": \"call_04\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.600Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T12:00:03.145Z\",\n \"ended_at\": \"2026-09-03T12:00:06.595Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 84,\n \"input_cache_read\": 9500,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:03.350Z\",\n \"tool_call_id\": \"call_05\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"in_progress\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:05.500Z\",\n \"tool_call_id\": \"call_06\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 passed (1)\\n Tests 6 passed (6)\\n\",\n \"exit_code\": 0\n }\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:05.650Z\",\n \"tool_call_id\": \"call_07\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"TodoWrite\",\n \"state\": \"done\",\n \"input\": {\n \"items\": [\n {\n \"title\": \"查看登录页代码,定位白屏原因\",\n \"status\": \"done\"\n },\n {\n \"title\": \"修复崩溃\",\n \"status\": \"done\"\n },\n {\n \"title\": \"跑测试验证\",\n \"status\": \"done\"\n }\n ]\n },\n \"output\": {\n \"updated\": true\n },\n \"todo_id\": \"td_01\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_07\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T12:00:06.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"修好了:`handleLogin` 在 `user` 为空时直接读 `token` 导致白屏,已加可选链判空;登录相关 6 个测试全部通过。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_07/history?page_size=50", + "chip": "REST", + "tail": "要点
· TodoWrite 的 tool_call 落盘(动作痕迹);todo 实体**不进历史**——面板状态由最后一个 TodoWrite done 的 input 还原,单一事实源
· 在流时 todo 由 §9 状态实体全量重发;空闲会话由历史里的 TodoWrite 还原——两条路径都不需要 todo 实体落盘" + } + ] + }, + { + "id": "question", + "title": "提问(question)", + "desc": "agent 发现改动范围不明确,挂出 question 等用户选择;回答后继续执行。与审批共用 interaction 实体。会话 s_08。", + "sections": [ + { + "label": "起跑与提问(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把 README 的安装命令更新成 pnpm\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T14:00:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T14:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_08\",\n \"timestamp\": \"2026-09-03T14:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788444000015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T14:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"安装命令在快速开始和开发者文档各有一处。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"范围不明,先问用户。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"安装命令在快速开始和开发者文档各有一处。范围不明,先问用户。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"README 里有两处安装命令,先确认范围:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"README 里有两处安装命令,先确认范围:\"\n}" + }, + { + "note": "提问挂出——`kind: question`,`request` 是问题列表", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:01.500Z\",\n \"interaction_id\": \"q_01\",\n \"kind\": \"question\",\n \"state\": \"pending\",\n \"request\": {\n \"questions\": [\n {\n \"id\": \"q1\",\n \"question\": \"README 里有两处安装命令(快速开始、开发者文档),要都更新吗?\",\n \"options\": [\n \"两处都改\",\n \"只改快速开始\"\n ]\n }\n ]\n }\n}" + }, + { + "note": "聚合快照:`pending_interaction: question`", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_08\",\n \"timestamp\": \"2026-09-03T14:00:01.520Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"question\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"awaiting_question\",\n \"turn_id\": 0,\n \"step\": 0,\n \"since\": 1788444001500\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2300,\n \"output\": 48,\n \"input_cache_read\": 7600,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2300,\n \"output\": 48,\n \"input_cache_read\": 7600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4150,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· question 与 approval 共用 interaction 实体:kind 区分,状态机相同(pending → 终态);question 的终态是 answered
· question 不绑定 tool_call(没有 tool_call_id)——它是 agent 主动发问,不是工具待批" + }, + { + "label": "回答与继续(WS)", + "items": [ + { + "note": "用户选「两处都改」(REST `POST /sessions/s_08/questions/q_01:answer`,不在 WS)。interaction 到 answered 终态,`response` 携带答案", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.100Z\",\n \"interaction_id\": \"q_01\",\n \"kind\": \"question\",\n \"state\": \"answered\",\n \"request\": {\n \"questions\": [\n {\n \"id\": \"q1\",\n \"question\": \"README 里有两处安装命令(快速开始、开发者文档),要都更新吗?\",\n \"options\": [\n \"两处都改\",\n \"只改快速开始\"\n ]\n }\n ]\n },\n \"response\": {\n \"answers\": {\n \"q1\": \"两处都改\"\n }\n }\n}" + }, + { + "note": "聚合快照回到运行态", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_08\",\n \"timestamp\": \"2026-09-03T14:00:05.110Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788444000015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2300,\n \"output\": 48,\n \"input_cache_read\": 7600,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2300,\n \"output\": 48,\n \"input_cache_read\": 7600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4150,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "按答案执行:先改快速开始", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.300Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"README.md\",\n \"old\": \"npm install && npm run dev\",\n \"new\": \"pnpm install && pnpm dev\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.800Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"README.md\",\n \"old\": \"npm install && npm run dev\",\n \"new\": \"pnpm install && pnpm dev\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.900Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T14:00:00.022Z\",\n \"ended_at\": \"2026-09-03T14:00:05.900Z\",\n \"usage\": {\n \"input_other\": 2300,\n \"output\": 48,\n \"input_cache_read\": 7600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带工具结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.950Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T14:00:05.950Z\"\n}" + }, + { + "note": "再改开发者文档", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:06.100Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"README.md\",\n \"old\": \"npm run build && npm start\",\n \"new\": \"pnpm build && pnpm start\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:06.500Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"README.md\",\n \"old\": \"npm run build && npm start\",\n \"new\": \"pnpm build && pnpm start\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "总结正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:06.900Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:06.900Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"两处都改好了:快速开始和开发者文档的安装命令已更新为 `pnpm` 版。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.200Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"两处都改好了:快速开始和开发者文档的安装命令已更新为 `pnpm` 版。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.200Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T14:00:05.950Z\",\n \"ended_at\": \"2026-09-03T14:00:07.200Z\",\n \"usage\": {\n \"input_other\": 2500,\n \"output\": 62,\n \"input_cache_read\": 8800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T14:00:00.015Z\",\n \"ended_at\": \"2026-09-03T14:00:07.300Z\",\n \"usage\": {\n \"input_tokens\": 4800,\n \"output_tokens\": 110\n },\n \"duration_ms\": 7283,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "user 消息随 turn 收官(补漏写帧)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.300Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把 README 的安装命令更新成 pnpm\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T14:00:00.000Z\",\n \"finished_at\": \"2026-09-03T14:00:07.300Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_08\",\n \"timestamp\": \"2026-09-03T14:00:07.320Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 4800,\n \"output\": 110,\n \"input_cache_read\": 16400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4900,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 答案只经 interaction.response 下发——操作(REST)的效果只经消息流呈现(§10),客户端等的是实体消息而不是另一个通道的事件" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_08/history?page_size=50`", + "json": "{\n \"session_id\": \"s_08\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T14:00:00.012Z\",\n \"ended_at\": \"2026-09-03T14:00:07.295Z\",\n \"usage\": {\n \"input_tokens\": 4800,\n \"output_tokens\": 110\n },\n \"duration_ms\": 7283,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.900Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T14:00:00.020Z\",\n \"ended_at\": \"2026-09-03T14:00:05.895Z\",\n \"usage\": {\n \"input_other\": 2300,\n \"output\": 48,\n \"input_cache_read\": 7600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把 README 的安装命令更新成 pnpm\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T14:00:00.000Z\",\n \"finished_at\": \"2026-09-03T14:00:07.295Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"安装命令在快速开始和开发者文档各有一处。范围不明,先问用户。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"README 里有两处安装命令,先确认范围:\"\n },\n {\n \"type\": \"interaction\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.100Z\",\n \"interaction_id\": \"q_01\",\n \"kind\": \"question\",\n \"state\": \"answered\",\n \"request\": {\n \"questions\": [\n {\n \"id\": \"q1\",\n \"question\": \"README 里有两处安装命令(快速开始、开发者文档),要都更新吗?\",\n \"options\": [\n \"两处都改\",\n \"只改快速开始\"\n ]\n }\n ]\n },\n \"response\": {\n \"answers\": {\n \"q1\": \"两处都改\"\n }\n }\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:05.800Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"README.md\",\n \"old\": \"npm install && npm run dev\",\n \"new\": \"pnpm install && pnpm dev\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.200Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T14:00:05.945Z\",\n \"ended_at\": \"2026-09-03T14:00:07.195Z\",\n \"usage\": {\n \"input_other\": 2500,\n \"output\": 62,\n \"input_cache_read\": 8800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:06.500Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"README.md\",\n \"old\": \"npm run build && npm start\",\n \"new\": \"pnpm build && pnpm start\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_08\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T14:00:07.100Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"两处都改好了:快速开始和开发者文档的安装命令已更新为 `pnpm` 版。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_08/history?page_size=50", + "chip": "REST", + "tail": "要点
· question 的终态落盘:问了什么、答了什么(request / response)都留在历史里;pending 状态不进历史" + } + ] + }, + { + "id": "tool-error", + "title": "工具失败(error)", + "desc": "Edit 的 old_string 没匹配上:tool_call 以 error 收官,agent 重读文件后改对。会话 s_09。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把配置里的超时时间改成 30 秒\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T15:00:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T15:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_09\",\n \"timestamp\": \"2026-09-03T15:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788447600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T15:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"超时配置在 config/server.toml,直接 Edit。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"超时配置在 config/server.toml,直接 Edit。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我来改超时配置:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来改超时配置:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"timeout = 10\",\n \"new\": \"timeout = 30\"\n }\n}" + }, + { + "note": "工具级失败:`state: error` + `error` 原因,无 `output`", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.900Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"error\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"timeout = 10\",\n \"new\": \"timeout = 30\"\n },\n \"error\": \"old_string 在文件中未找到\"\n}" + }, + { + "note": "error 就是本次工具结果,step 照常收官", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.000Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T15:00:00.022Z\",\n \"ended_at\": \"2026-09-03T15:00:02.000Z\",\n \"usage\": {\n \"input_other\": 2300,\n \"output\": 46,\n \"input_cache_read\": 7200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带失败结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.050Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T15:00:02.050Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.400Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.400Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"没匹配上,我先看下文件实际内容:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"没匹配上,我先看下文件实际内容:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.800Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Read\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"config/server.toml\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:03.200Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"config/server.toml\"\n },\n \"output\": {\n \"content\": \"[server]\\nrequest_timeout = 10\\n…\",\n \"lines\": 18\n }\n}" + }, + { + "note": "字段名其实是 `request_timeout`,改对再来", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:03.400Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"request_timeout = 10\",\n \"new\": \"request_timeout = 30\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:03.800Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"request_timeout = 10\",\n \"new\": \"request_timeout = 30\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "总结正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.200Z\",\n \"message_id\": \"t1.1.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.200Z\",\n \"message_id\": \"t1.1.a1\",\n \"text\": \"改好了:超时配置项是 `request_timeout`(不是 `timeout`),已从 10 改为 30。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.500Z\",\n \"message_id\": \"t1.1.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"改好了:超时配置项是 `request_timeout`(不是 `timeout`),已从 10 改为 30。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.500Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T15:00:02.050Z\",\n \"ended_at\": \"2026-09-03T15:00:04.500Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 82,\n \"input_cache_read\": 10400,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.600Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T15:00:00.015Z\",\n \"ended_at\": \"2026-09-03T15:00:04.600Z\",\n \"usage\": {\n \"input_tokens\": 5100,\n \"output_tokens\": 128\n },\n \"duration_ms\": 4583,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.600Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把配置里的超时时间改成 30 秒\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T15:00:00.000Z\",\n \"finished_at\": \"2026-09-03T15:00:04.600Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_09\",\n \"timestamp\": \"2026-09-03T15:00:04.620Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5100,\n \"output\": 128,\n \"input_cache_read\": 17600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5000,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· error 是**工具级**终态:error 字段带原因、无 output。命令执行了但非零退出**不算** error——那是 done + output.exit_code(例 6 的 exit 1),两种「失败」分开
· 一个 step 可以有多条 assistant 消息(t1.1.a0 过渡、t1.1.a1 总结),按 id 各自 upsert
· error 就是本次工具结果:agent 拿它继续(重读文件 → 改对字段名),不需要任何特殊消息" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_09/history?page_size=50`", + "json": "{\n \"session_id\": \"s_09\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.600Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T15:00:00.012Z\",\n \"ended_at\": \"2026-09-03T15:00:04.595Z\",\n \"usage\": {\n \"input_tokens\": 5100,\n \"output_tokens\": 128\n },\n \"duration_ms\": 4583,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.000Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T15:00:00.020Z\",\n \"ended_at\": \"2026-09-03T15:00:01.995Z\",\n \"usage\": {\n \"input_other\": 2300,\n \"output\": 46,\n \"input_cache_read\": 7200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把配置里的超时时间改成 30 秒\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T15:00:00.000Z\",\n \"finished_at\": \"2026-09-03T15:00:04.595Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"超时配置在 config/server.toml,直接 Edit。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来改超时配置:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:01.900Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"error\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"timeout = 10\",\n \"new\": \"timeout = 30\"\n },\n \"error\": \"old_string 在文件中未找到\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.500Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T15:00:02.045Z\",\n \"ended_at\": \"2026-09-03T15:00:04.495Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 82,\n \"input_cache_read\": 10400,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:02.600Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"没匹配上,我先看下文件实际内容:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:03.200Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"config/server.toml\"\n },\n \"output\": {\n \"content\": \"[server]\\nrequest_timeout = 10\\n…\",\n \"lines\": 18\n }\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:03.800Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"request_timeout = 10\",\n \"new\": \"request_timeout = 30\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_09\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:00:04.400Z\",\n \"message_id\": \"t1.1.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"改好了:超时配置项是 `request_timeout`(不是 `timeout`),已从 10 改为 30。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_09/history?page_size=50", + "chip": "REST", + "tail": "要点
· error 终态落盘(含失败原因)——刷新后能看到「这一步失败过、随后这样恢复」的完整过程" + } + ] + }, + { + "id": "llm-retry", + "title": "LLM 重试(step.retry)", + "desc": "LLM 请求 429:step 带 retry 字段 upsert,2 秒后重试成功,内容重新流出。会话 s_10。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"总结一下这个项目的目录结构\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T15:30:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T15:30:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_10\",\n \"timestamp\": \"2026-09-03T15:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788449400015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T15:30:00.022Z\"\n}" + }, + { + "note": "step 实体 upsert:`retry` 字段标出「第 1 次失败,2 秒后进行第 2 次(共 3 次)」——UI 显示「请求失败,正在重试」", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:00.500Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T15:30:00.022Z\",\n \"retry\": {\n \"failed_attempt\": 1,\n \"next_attempt\": 2,\n \"max_attempts\": 3,\n \"delay_ms\": 2000,\n \"error_name\": \"RateLimitError\",\n \"error_message\": \"429 too many requests\",\n \"status_code\": 429\n }\n}" + }, + { + "note": "重试成功,thinking 重新流出(同 message_id,重新走 streaming → delta → completed)", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:02.950Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:02.950Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"先列顶层目录,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:03.100Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"再按功能分组说明。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:03.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先列顶层目录,再按功能分组说明。\"\n}" + }, + { + "note": "正文输出", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:03.700Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:03.700Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"项目分四块:`apps/` 三个端(desktop、web、auth-login)、\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:03.900Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"`packages/` 八个共享包、`scripts/` 构建发布脚本、`docs/` 设计文档。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"项目分四块:`apps/` 三个端(desktop、web、auth-login)、`packages/` 八个共享包、`scripts/` 构建发布脚本、`docs/` 设计文档。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.200Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T15:30:00.022Z\",\n \"ended_at\": \"2026-09-03T15:30:04.200Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 62,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T15:30:00.015Z\",\n \"ended_at\": \"2026-09-03T15:30:04.300Z\",\n \"usage\": {\n \"input_tokens\": 2400,\n \"output_tokens\": 62\n },\n \"duration_ms\": 4283,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.300Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"总结一下这个项目的目录结构\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T15:30:00.000Z\",\n \"finished_at\": \"2026-09-03T15:30:04.300Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_10\",\n \"timestamp\": \"2026-09-03T15:30:04.320Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2400,\n \"output\": 62,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4300,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 重试经 step.retry 表达:step 实体 upsert(replace-by-id),retry 标出第几次失败、下次尝试、退避毫秒与错误详情
· 重试 = 重新生成:内容类消息重新走 streaming → delta → completed 节律,completed 权威覆盖重试前的半截(若有)
· 重试不产生新 id:step_id / message_id 全部不变——客户端无需任何去重逻辑" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "turn 完成后刷新页面,`GET /api/v1/sessions/s_10/history?page_size=50`", + "json": "{\n \"session_id\": \"s_10\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.300Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T15:30:00.012Z\",\n \"ended_at\": \"2026-09-03T15:30:04.295Z\",\n \"usage\": {\n \"input_tokens\": 2400,\n \"output_tokens\": 62\n },\n \"duration_ms\": 4283,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.200Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T15:30:00.020Z\",\n \"ended_at\": \"2026-09-03T15:30:04.195Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 62,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"总结一下这个项目的目录结构\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T15:30:00.000Z\",\n \"finished_at\": \"2026-09-03T15:30:04.295Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:03.300Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先列顶层目录,再按功能分组说明。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_10\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T15:30:04.100Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"项目分四块:`apps/` 三个端(desktop、web、auth-login)、`packages/` 八个共享包、`scripts/` 构建发布脚本、`docs/` 设计文档。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_10/history?page_size=50", + "chip": "REST", + "tail": "要点
· retry 是过程状态,不落盘——历史里就是一个普通的 completed step(重试痕迹只留在服务端日志)" + } + ] + }, + { + "id": "background-task", + "title": "后台任务(task)", + "desc": "完整构建超过前台阈值转后台:task 实体接管,tool_call 以 task_id 收官;几分钟后任务完成,触发 origin: task 的新 turn。会话 s_11。", + "sections": [ + { + "label": "转后台(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下完整构建\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T16:00:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T16:00:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_11\",\n \"timestamp\": \"2026-09-03T16:00:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788451200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T16:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"完整构建要几分钟,先跑起来,太久就转后台。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"完整构建要几分钟,先跑起来,太久就转后台。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我来跑完整构建:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑完整构建:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm build\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool.progress\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:03.000Z\",\n \"tool_call_id\": \"call_01\",\n \"progress\": {\n \"kind\": \"stdout\",\n \"text\": \"… compiling packages (3/8) …\"\n }\n}" + }, + { + "note": "命令超过前台阈值,转后台——`task` 实体接管(detached,全量 replace-by-id)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.000Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"shell\",\n \"state\": \"running\",\n \"detached\": true,\n \"description\": \"pnpm build\",\n \"output_tail\": \"… compiling packages (3/8) …\",\n \"started_at\": \"2026-09-03T16:00:04.000Z\"\n}" + }, + { + "note": "tool_call 以 `task_id` 收官:前台部分结束", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.010Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm build\"\n },\n \"output\": {\n \"detached\": true,\n \"task_id\": \"task_01\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:00:00.022Z\",\n \"ended_at\": \"2026-09-03T16:00:04.100Z\",\n \"usage\": {\n \"input_other\": 2500,\n \"output\": 58,\n \"input_cache_read\": 9200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.150Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T16:00:04.150Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"构建量比较大,已转后台跑(task_01),完成后我告诉你。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"构建量比较大,已转后台跑(task_01),完成后我告诉你。\"\n}" + }, + { + "note": "turn 收官,会话回闲——任务在后台继续", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.800Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:00:04.150Z\",\n \"ended_at\": \"2026-09-03T16:00:04.800Z\",\n \"usage\": {\n \"input_other\": 2700,\n \"output\": 66,\n \"input_cache_read\": 10100,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.900Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T16:00:00.015Z\",\n \"ended_at\": \"2026-09-03T16:00:04.900Z\",\n \"usage\": {\n \"input_tokens\": 5200,\n \"output_tokens\": 124\n },\n \"duration_ms\": 4883,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.900Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下完整构建\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T16:00:00.000Z\",\n \"finished_at\": \"2026-09-03T16:00:04.900Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_11\",\n \"timestamp\": \"2026-09-03T16:00:04.920Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5200,\n \"output\": 124,\n \"input_cache_read\": 19300,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5300,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 转后台 = taskdetached: true)+ tool_call 以 task_id 收官:前台部分结束,任务由 task 实体接管跟踪
· task 是全量实体(replace-by-id):output_tail 恒为输出**末尾窗口**,进度更新就是整体重发" + }, + { + "label": "任务完成与新 turn(WS)", + "items": [ + { + "note": "几分钟后:构建接近尾声,`output_tail` 更新(整体重发,同 task_id)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:30.000Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"shell\",\n \"state\": \"running\",\n \"detached\": true,\n \"description\": \"pnpm build\",\n \"output_tail\": \"… packages (8/8) done, writing dist …\",\n \"started_at\": \"2026-09-03T16:00:04.000Z\"\n}" + }, + { + "note": "任务完成(`result_summary` 带结论)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:58.500Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"shell\",\n \"state\": \"completed\",\n \"detached\": true,\n \"description\": \"pnpm build\",\n \"output_tail\": \"… build finished successfully in 7m 56s …\",\n \"started_at\": \"2026-09-03T16:00:04.000Z\",\n \"ended_at\": \"2026-09-03T16:07:58.500Z\",\n \"result_summary\": \"构建成功:8 个包全部编译通过\"\n}" + }, + { + "note": "后台完成通知 = 特殊 user 消息(origin task + 结构化 notification)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:58.510Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"后台构建完成\\n构建成功:8 个包全部编译通过\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T16:07:58.510Z\",\n \"finished_at\": \"2026-09-03T16:07:58.510Z\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"notification\": {\n \"title\": \"后台构建完成\",\n \"body\": \"构建成功:8 个包全部编译通过\",\n \"severity\": \"info\",\n \"type\": \"task.completed\",\n \"source_kind\": \"background_task\",\n \"source_id\": \"task_01\"\n }\n}" + }, + { + "note": "任务完成触发新 turn——`origin: task`,agent 主动汇报不需要用户 prompt", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:58.600Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T16:07:58.600Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_11\",\n \"timestamp\": \"2026-09-03T16:07:58.610Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788451678600\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5200,\n \"output\": 124,\n \"input_cache_read\": 19300,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5300,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:58.620Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T16:07:58.620Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.000Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.000Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"构建完成了:8 个包全部编译通过,产物在各自的 `dist/`。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.300Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"构建完成了:8 个包全部编译通过,产物在各自的 `dist/`。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.300Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:07:58.620Z\",\n \"ended_at\": \"2026-09-03T16:07:59.300Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 44,\n \"input_cache_read\": 12000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.400Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T16:07:58.600Z\",\n \"ended_at\": \"2026-09-03T16:07:59.400Z\",\n \"usage\": {\n \"input_tokens\": 2900,\n \"output_tokens\": 44\n },\n \"duration_ms\": 797\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_11\",\n \"timestamp\": \"2026-09-03T16:07:59.410Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 8100,\n \"output\": 168,\n \"input_cache_read\": 31300,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5600,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· origin: task 的 turn 没有 user_message_id——它不是用户触发的;时间线上与 user turn 同样渲染,封面注明来源
· 后台任务不阻塞会话:t1 在转后台后即收官;t2 是任务完成后的独立一轮" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "全部完成后刷新页面,`GET /api/v1/sessions/s_11/history?page_size=50`", + "json": "{\n \"session_id\": \"s_11\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.900Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T16:00:00.012Z\",\n \"ended_at\": \"2026-09-03T16:00:04.895Z\",\n \"usage\": {\n \"input_tokens\": 5200,\n \"output_tokens\": 124\n },\n \"duration_ms\": 4883,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:00:00.020Z\",\n \"ended_at\": \"2026-09-03T16:00:04.095Z\",\n \"usage\": {\n \"input_other\": 2500,\n \"output\": 58,\n \"input_cache_read\": 9200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下完整构建\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T16:00:00.000Z\",\n \"finished_at\": \"2026-09-03T16:00:04.895Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"完整构建要几分钟,先跑起来,太久就转后台。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑完整构建:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.010Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm build\"\n },\n \"output\": {\n \"detached\": true,\n \"task_id\": \"task_01\"\n }\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.800Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:00:04.145Z\",\n \"ended_at\": \"2026-09-03T16:00:04.795Z\",\n \"usage\": {\n \"input_other\": 2700,\n \"output\": 66,\n \"input_cache_read\": 10100,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:00:04.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"构建量比较大,已转后台跑(task_01),完成后我告诉你。\"\n },\n {\n \"type\": \"task\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:58.500Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"shell\",\n \"state\": \"completed\",\n \"detached\": true,\n \"description\": \"pnpm build\",\n \"output_tail\": \"… build finished successfully in 7m 56s …\",\n \"started_at\": \"2026-09-03T16:00:01.500Z\",\n \"ended_at\": \"2026-09-03T16:07:58.495Z\",\n \"result_summary\": \"构建成功:8 个包全部编译通过\"\n },\n {\n \"type\": \"turn\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.400Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"user_message_id\": \"t2.u0\",\n \"started_at\": \"2026-09-03T16:07:58.598Z\",\n \"ended_at\": \"2026-09-03T16:07:59.395Z\",\n \"usage\": {\n \"input_tokens\": 2900,\n \"output_tokens\": 44\n },\n \"duration_ms\": 797\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.300Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:07:58.618Z\",\n \"ended_at\": \"2026-09-03T16:07:59.295Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 44,\n \"input_cache_read\": 12000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:58.598Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"后台构建完成\\n构建成功:8 个包全部编译通过\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T16:07:58.598Z\",\n \"finished_at\": \"2026-09-03T16:07:59.395Z\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"notification\": {\n \"title\": \"后台构建完成\",\n \"body\": \"构建成功:8 个包全部编译通过\",\n \"severity\": \"info\",\n \"type\": \"task.completed\",\n \"source_kind\": \"background_task\",\n \"source_id\": \"task_01\"\n }\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_11\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:07:59.200Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"构建完成了:8 个包全部编译通过,产物在各自的 `dist/`。\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_11/history?page_size=50", + "chip": "REST", + "tail": "要点
· task 终态落盘(含 result_summary)——任务结果可回看;running 中的 task 则经 §9 状态实体全量重发" + } + ] + }, + { + "id": "compaction", + "title": "compaction(上下文压缩)", + "desc": "会话很长(t1..t8,context 241k):新一轮起跑时先压缩,marker 留痕、context_tokens 骤降,随后正常执行。会话 s_12。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "会话已很长(t1..t8,context 241k/262k)。用户提交 prompt(REST,不在 WS)。随后 WS 上;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.010Z\",\n \"message_id\": \"t9.u0\",\n \"turn_id\": \"t9\",\n \"text\": \"接着上面的讨论,把新页面的路由也加上\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T16:30:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.015Z\",\n \"turn_id\": \"t9\",\n \"ordinal\": 8,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T16:30:00.015Z\",\n \"user_message_id\": \"t9.u0\"\n}" + }, + { + "note": "起跑时的聚合快照:context 241000", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_12\",\n \"timestamp\": \"2026-09-03T16:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 8,\n \"step\": 0,\n \"step_id\": \"t9.0\",\n \"since\": 1788453000015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 20337,\n \"output\": 4398,\n \"input_cache_read\": 128912,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 241000,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "上下文将满,先压缩——时间线上留一条标记(创建即完整)", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.100Z\",\n \"payload\": {\n \"before_tokens\": 241000,\n \"after_tokens\": 62000,\n \"summarized_through_turn\": \"t8\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"compaction\"\n}" + }, + { + "note": "压缩后的聚合快照:context_tokens 骤降", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_12\",\n \"timestamp\": \"2026-09-03T16:30:00.110Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 8,\n \"step\": 0,\n \"step_id\": \"t9.0\",\n \"since\": 1788453000015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 20337,\n \"output\": 4398,\n \"input_cache_read\": 128912,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 62000,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.120Z\",\n \"step_id\": \"t9.0\",\n \"turn_id\": \"t9\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T16:30:00.120Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.550Z\",\n \"message_id\": \"t9.0.h0\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.550Z\",\n \"message_id\": \"t9.0.h0\",\n \"text\": \"路由集中在 router 配置文件,加一条即可。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.150Z\",\n \"message_id\": \"t9.0.h0\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"completed\",\n \"text\": \"路由集中在 router 配置文件,加一条即可。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.150Z\",\n \"message_id\": \"t9.0.a0\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.150Z\",\n \"message_id\": \"t9.0.a0\",\n \"text\": \"我来加路由:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.500Z\",\n \"message_id\": \"t9.0.a0\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"completed\",\n \"text\": \"我来加路由:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/router.ts\",\n \"old\": \" { path: '/login', component: LoginView },\",\n \"new\": \" { path: '/login', component: LoginView },\\n { path: '/new-page', component: NewPageView },\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.900Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/router.ts\",\n \"old\": \" { path: '/login', component: LoginView },\",\n \"new\": \" { path: '/login', component: LoginView },\\n { path: '/new-page', component: NewPageView },\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "总结正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.300Z\",\n \"message_id\": \"t9.0.a1\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.300Z\",\n \"message_id\": \"t9.0.a1\",\n \"text\": \"加好了:`/new-page` 路由已注册到 `router.ts`。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.600Z\",\n \"message_id\": \"t9.0.a1\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"completed\",\n \"text\": \"加好了:`/new-page` 路由已注册到 `router.ts`。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.600Z\",\n \"step_id\": \"t9.0\",\n \"turn_id\": \"t9\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:30:00.120Z\",\n \"ended_at\": \"2026-09-03T16:30:02.600Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 72,\n \"input_cache_read\": 9600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.700Z\",\n \"turn_id\": \"t9\",\n \"ordinal\": 8,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T16:30:00.015Z\",\n \"ended_at\": \"2026-09-03T16:30:02.700Z\",\n \"usage\": {\n \"input_tokens\": 3100,\n \"output_tokens\": 72\n },\n \"duration_ms\": 2683,\n \"user_message_id\": \"t9.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.700Z\",\n \"message_id\": \"t9.u0\",\n \"turn_id\": \"t9\",\n \"text\": \"接着上面的讨论,把新页面的路由也加上\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T16:30:00.000Z\",\n \"finished_at\": \"2026-09-03T16:30:02.700Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_12\",\n \"timestamp\": \"2026-09-03T16:30:02.720Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 20600,\n \"output\": 4460,\n \"input_cache_read\": 129500,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 65200,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· compaction 只留一条 system(compaction):时间线上的「此处压缩过」标记,payload 带前后 token 数与压缩范围
· 压缩的是 **LLM 上下文**,不改写时间线:REST 历史仍返回全部内容;变化只体现在 session.state.context_tokens 骤降(241000 → 62000)" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "刷新页面,`GET /api/v1/sessions/s_12/history?page_size=50`——响应为最后一页(`has_more: true`,更早的 t1..t8 用 `before_turn=t9` 往前翻);compaction 不删历史,system 与 t9 全部实体都在", + "json": "{\n \"session_id\": \"s_12\",\n \"items\": [\n {\n \"type\": \"system\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.100Z\",\n \"payload\": {\n \"before_tokens\": 241000,\n \"after_tokens\": 62000,\n \"summarized_through_turn\": \"t8\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"compaction\"\n },\n {\n \"type\": \"turn\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.700Z\",\n \"turn_id\": \"t9\",\n \"ordinal\": 8,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T16:30:00.012Z\",\n \"ended_at\": \"2026-09-03T16:30:02.695Z\",\n \"usage\": {\n \"input_tokens\": 3100,\n \"output_tokens\": 72\n },\n \"duration_ms\": 2683,\n \"user_message_id\": \"t9.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.600Z\",\n \"step_id\": \"t9.0\",\n \"turn_id\": \"t9\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T16:30:00.118Z\",\n \"ended_at\": \"2026-09-03T16:30:02.595Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 72,\n \"input_cache_read\": 9600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.010Z\",\n \"message_id\": \"t9.u0\",\n \"turn_id\": \"t9\",\n \"text\": \"接着上面的讨论,把新页面的路由也加上\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T16:30:00.000Z\",\n \"finished_at\": \"2026-09-03T16:30:02.695Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:00.800Z\",\n \"message_id\": \"t9.0.h0\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"completed\",\n \"text\": \"路由集中在 router 配置文件,加一条即可。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.300Z\",\n \"message_id\": \"t9.0.a0\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"completed\",\n \"text\": \"我来加路由:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:01.900Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/router.ts\",\n \"old\": \" { path: '/login', component: LoginView },\",\n \"new\": \" { path: '/login', component: LoginView },\\n { path: '/new-page', component: NewPageView },\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_12\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T16:30:02.500Z\",\n \"message_id\": \"t9.0.a1\",\n \"turn_id\": \"t9\",\n \"step_id\": \"t9.0\",\n \"status\": \"completed\",\n \"text\": \"加好了:`/new-page` 路由已注册到 `router.ts`。\"\n }\n ],\n \"has_more\": true,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_12/history?page_size=50", + "chip": "REST", + "tail": "要点
· system(compaction) 落盘在时间线原位;它之前的内容一条不少——compaction 是上下文事件,不是删除事件" + } + ] + }, + { + "id": "session-changes", + "title": "会话级变更(session / 设置)", + "desc": "t1 完成后自动起标题(session.updated);用户切模型、切 permission(session.state 全量重发)。会话 s_13。", + "sections": [ + { + "label": "自动标题(WS)", + "items": [ + { + "note": "前置:t1「登录页点登录直接白屏,修一下」刚完成(流同例 8),会话标题还是默认的「新会话」。t1 完成后服务端自动起标题——`session` 实体(索引级,`subtype: updated`)", + "json": "{\n \"type\": \"session\",\n \"timestamp\": \"2026-09-03T17:00:06.800Z\",\n \"subtype\": \"updated\",\n \"session\": {\n \"session_id\": \"s_13\",\n \"workspace_id\": \"ws_01\",\n \"title\": \"修复登录页白屏\",\n \"status\": \"active\",\n \"model\": \"kimi-k3-highspeed\",\n \"created_at\": \"2026-09-03T16:40:00.000Z\",\n \"updated_at\": \"2026-09-03T17:00:06.700Z\",\n \"turn_count\": 1\n },\n \"changed_fields\": [\n \"title\"\n ]\n}" + } + ], + "tail": "要点
· session 实体管**索引**(会话列表页):created / updated / archived / deleted 四 subtype;updatedchanged_fieldssession 为与 REST 同型的完整实体
· 自动标题是服务端行为:turn 完成后异步生成,客户端收到 session.updated 后刷新列表项即可" + }, + { + "label": "设置变更(WS)", + "items": [ + { + "note": "用户把模型从 kimi-k3-highspeed 切到 kimi-k3(REST `PUT /sessions/s_13/settings`,不在 WS)。会话内状态变更走 `session.state` 全量重发——读最新一条即可", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_13\",\n \"timestamp\": \"2026-09-03T17:02:10.000Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5500,\n \"output\": 162,\n \"input_cache_read\": 19500,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5700,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "用户再把 permission 切到 yolo", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_13\",\n \"timestamp\": \"2026-09-03T17:02:40.000Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3\",\n \"permission\": \"yolo\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5500,\n \"output\": 162,\n \"input_cache_read\": 19500,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5700,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 会话内状态(model / permission / modes)变更只有一条路:session.state 全量重发。客户端不做跨消息推理,读最新一条(§7.10 累计字段纪律)
· 分工:session 管「列表里的会话」,session.state 管「会话内的状态」" + } + ] + }, + { + "id": "global", + "title": "全局消息(workspace / config / 通知)", + "desc": "不属任何会话的三类全局消息:索引(workspace)、配置(config 全量内联)、大实体变更通知(catalog/plugin/capability → REST 重拉)。", + "sections": [ + { + "label": "工作区(WS)", + "items": [ + { + "note": "用户在客户端新建工作区(REST `POST /workspaces`,不在 WS)。全局消息无 `session_id`", + "json": "{\n \"type\": \"workspace\",\n \"timestamp\": \"2026-09-03T17:10:00.010Z\",\n \"subtype\": \"created\",\n \"workspace\": {\n \"id\": \"ws_02\",\n \"root\": \"/Users/moonshot/projects/demo\",\n \"name\": \"demo\",\n \"created_at\": \"2026-09-03T17:10:00.000Z\",\n \"last_opened_at\": \"2026-09-03T17:10:00.000Z\",\n \"session_count\": 0\n }\n}" + }, + { + "note": "工作区里开了第一个会话,`updated` 全量重发", + "json": "{\n \"type\": \"workspace\",\n \"timestamp\": \"2026-09-03T17:12:00.010Z\",\n \"subtype\": \"updated\",\n \"workspace\": {\n \"id\": \"ws_02\",\n \"root\": \"/Users/moonshot/projects/demo\",\n \"name\": \"demo\",\n \"created_at\": \"2026-09-03T17:10:00.000Z\",\n \"last_opened_at\": \"2026-09-03T17:12:00.000Z\",\n \"session_count\": 1\n }\n}" + } + ], + "tail": "要点
· workspace 实体三 subtype:created / updated / deleted;每次变更全量内联(小实体),客户端 replace-by-id
· 全局消息没有 session_id——所有连接都收,与会话订阅无关" + }, + { + "label": "全局配置(WS)", + "items": [ + { + "note": "用户改全局配置(REST `PUT /config`,不在 WS)。`config` 为脱敏全量 + `changed_fields`", + "json": "{\n \"type\": \"config\",\n \"timestamp\": \"2026-09-03T17:15:00.000Z\",\n \"config\": {\n \"model\": \"kimi-k3-highspeed\",\n \"theme\": \"dark\",\n \"permission\": \"manual\",\n \"max_turns\": 100,\n \"providers\": {\n \"anthropic\": {\n \"base_url\": \"https://api.anthropic.com\"\n }\n }\n },\n \"changed_fields\": [\n \"theme\"\n ]\n}" + } + ], + "tail": "要点
· 小实体全量内联:收到即整体替换本地配置,changed_fields 只用于高亮「哪项变了」
· 脱敏:密钥类字段不下发(服务端在投影层过滤)" + }, + { + "label": "变更通知(WS)", + "items": [ + { + "note": "模型目录有更新(新模型上架)——通知型:无载荷,客户端经 REST `GET /api/v1/models` 重拉", + "json": "{\n \"type\": \"model_catalog\",\n \"timestamp\": \"2026-09-03T17:20:00.000Z\"\n}" + }, + { + "note": "插件变更——REST `GET /api/v1/plugins` 重拉", + "json": "{\n \"type\": \"plugin\",\n \"timestamp\": \"2026-09-03T17:21:00.000Z\"\n}" + }, + { + "note": "能力变更(某个 MCP server 上线)——REST `GET /api/v1/capabilities` 重拉", + "json": "{\n \"type\": \"capability\",\n \"timestamp\": \"2026-09-03T17:22:00.000Z\",\n \"capability_id\": \"mcp.github\"\n}" + } + ], + "tail": "要点
· 大实体只发变更通知 + REST 拉取:model_catalog / plugin / capability 三类;除此三类外**不允许**出现通知型消息(§7.12 规则)
· 拉取结果与 WS 消息内嵌实体共享 zod schema——同一份数据两种取法,形状一致" + } + ] + }, + { + "id": "connection", + "title": "握手 omit 与背压", + "desc": "omit 裁剪订阅:不要 delta 族的客户端照样完整;背压:慢消费者溢出 → error 断开 → 重连走 §9 恢复。会话 s_15。", + "sections": [ + { + "label": "omit 订阅(WS)", + "items": [ + { + "note": "连接建立即发 hello", + "json": "{\n \"type\": \"hello\",\n \"protocol_version\": 2,\n \"server_id\": \"srv_9f2c\",\n \"capabilities\": [\n \"step_replay_v1\",\n \"interaction_v1\"\n ]\n}" + }, + { + "note": "客户端订阅(C→S)——`omit` 按消息 type 精确屏蔽下行:这个客户端不逐字渲染,不要 delta 族", + "json": "{\n \"type\": \"subscribe\",\n \"id\": 1,\n \"session_id\": \"s_15\",\n \"omit\": [\n \"assistant.delta\",\n \"thinking.delta\",\n \"tool_call.delta\"\n ]\n}" + }, + { + "note": "确认(S→C)", + "json": "{\n \"type\": \"ack\",\n \"id\": 1,\n \"code\": 0\n}" + }, + { + "note": "此后一个完整 turn 里没有任何 delta:实体消息照常,delta 族一条都不下发", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"今天天气怎么样\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T17:20:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:20:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_15\",\n \"timestamp\": \"2026-09-03T17:20:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788456000015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:20:00.022Z\"\n}" + }, + { + "note": "thinking 只有占位与终值", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:00.300Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:00.900Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"闲聊类问题,直接友好回答。\"\n}" + }, + { + "note": "assistant 同样:streaming 占位后直接 completed 全量", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:00.900Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:01.400Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我没法查实时天气——告诉你城市的话,我可以聊聊一般的气候特点。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:01.400Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:20:00.022Z\",\n \"ended_at\": \"2026-09-03T17:20:01.400Z\",\n \"usage\": {\n \"input_other\": 1820,\n \"output\": 28,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:01.500Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:20:00.015Z\",\n \"ended_at\": \"2026-09-03T17:20:01.500Z\",\n \"usage\": {\n \"input_tokens\": 1820,\n \"output_tokens\": 28\n },\n \"duration_ms\": 1483,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_15\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:20:01.500Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"今天天气怎么样\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T17:20:00.000Z\",\n \"finished_at\": \"2026-09-03T17:20:01.500Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_15\",\n \"timestamp\": \"2026-09-03T17:20:01.520Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 1820,\n \"output\": 28,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1870,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· omit 是订阅参数不是协议分档:屏蔽不影响正确性——delta 可丢(原则 4),completed 才是权威,不要 delta 的客户端照样完整
· 被屏蔽的只有 delta 族;实体消息(含 streaming 占位)照发——客户端仍能显示「正在生成」" + }, + { + "label": "背压断开(WS)", + "items": [ + { + "note": "消费者过慢,本连接的出站有界队列溢出——服务端以专用错误码断开(慢客户端的代价是一次恢复,系统内存安全不受影响)", + "json": "{\n \"type\": \"error\",\n \"code\": \"backpressure_overflow\",\n \"msg\": \"outbound queue overflow; connection closed, reconnect to resync\"\n}" + }, + { + "note": "连接断开。客户端重连——握手同前", + "json": "{\n \"type\": \"hello\",\n \"protocol_version\": 2,\n \"server_id\": \"srv_9f2c\",\n \"capabilities\": [\n \"step_replay_v1\",\n \"interaction_v1\"\n ]\n}" + }, + { + "note": "(C→S)", + "json": "{\n \"type\": \"subscribe\",\n \"id\": 1,\n \"session_id\": \"s_15\"\n}" + }, + { + "note": "确认(S→C)。随后是 §9 恢复载荷:turn 封面 + 在流 step 的内容 + 状态实体全量重发——与例 5「A · 刷新:WS 恢复与直播」逐条相同,此处不再重复", + "json": "{\n \"type\": \"ack\",\n \"id\": 1,\n \"code\": 0\n}" + } + ], + "tail": "要点
· 背压策略(§8):每连接出站**有界**队列,溢出即专用错误码断开;绝不为慢消费者无限堆积
· 断开后没有任何状态需要抢救:重连走 §9 恢复(无游标、无协商),与刷新/普通断线同一条服务端路径", + "branch_from": "" + } + ] + }, + { + "id": "subagent", + "title": "子代理(subagent)", + "desc": "起 reviewer 子代理审查改动:A 前台同步(普通 tool_call)/ B 调用即后台(task 接管)/ C 前台转后台(手动 detach);子代理消息流走按需订阅的子通道(末节)。会话 s_16。", + "sections": [ + { + "label": "A · 前台(主通道)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"白屏修好了,帮我审查一下改动\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T17:30:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:30:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788456600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"起个 reviewer 子代理独立审查,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"前台等它出结果。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"起个 reviewer 子代理独立审查,前台等它出结果。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我起一个审查子代理,前台等它:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我起一个审查子代理,前台等它:\"\n}" + }, + { + "note": "派生子代理——前台模式:主 step 阻塞在这条 tool_call 上", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Agent\",\n \"state\": \"running\",\n \"input\": {\n \"description\": \"审查 LoginView 的白屏修复\",\n \"agent_type\": \"reviewer\"\n },\n \"agent_refs\": [\n {\n \"agent_id\": \"review_01\",\n \"role\": \"child\"\n }\n ]\n}" + }, + { + "note": "主通道上看不到子代理干活(细节走子通道,末节);done 的 `output` 即结论", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.000Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Agent\",\n \"state\": \"done\",\n \"input\": {\n \"description\": \"审查 LoginView 的白屏修复\",\n \"agent_type\": \"reviewer\"\n },\n \"output\": {\n \"report\": \"审查通过:可选链修复正确,无回归风险。\"\n },\n \"agent_refs\": [\n {\n \"agent_id\": \"review_01\",\n \"role\": \"child\"\n }\n ]\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:00.022Z\",\n \"ended_at\": \"2026-09-03T17:30:04.100Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 72,\n \"input_cache_read\": 11000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带结论再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.150Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:04.150Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.500Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"子代理审查通过:修复正确,无回归风险,可以放心提交。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"子代理审查通过:修复正确,无回归风险,可以放心提交。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.800Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:04.150Z\",\n \"ended_at\": \"2026-09-03T17:30:04.800Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 80,\n \"input_cache_read\": 11800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.900Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:30:00.015Z\",\n \"ended_at\": \"2026-09-03T17:30:04.900Z\",\n \"usage\": {\n \"input_tokens\": 6000,\n \"output_tokens\": 152\n },\n \"duration_ms\": 4883,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.900Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"白屏修好了,帮我审查一下改动\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T17:30:00.000Z\",\n \"finished_at\": \"2026-09-03T17:30:04.900Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:04.920Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 6000,\n \"output\": 152,\n \"input_cache_read\": 22800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 6200,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 前台 = 普通 tool_call:主 step 阻塞在 running 的 tool_call 上,done 的 output 即子代理结论;无 task 实体
· 主通道上看不到子代理干活——只有 tool_call 的等待与收官;想看细节就订阅子通道(末节),前台模式同样能看" + }, + { + "label": "B · 后台(主通道)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"白屏修好了,帮我审查一下改动\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T17:30:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:30:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788456600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"起个后台子代理,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"结果回来再汇总。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"起个后台子代理,结果回来再汇总。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我起一个后台审查子代理:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我起一个后台审查子代理:\"\n}" + }, + { + "note": "`input.run_in_background: true`——调用即后台", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Agent\",\n \"state\": \"running\",\n \"input\": {\n \"description\": \"审查 LoginView 的白屏修复\",\n \"agent_type\": \"reviewer\",\n \"run_in_background\": true\n },\n \"agent_refs\": [\n {\n \"agent_id\": \"review_01\",\n \"role\": \"child\"\n }\n ]\n}" + }, + { + "note": "tool_call 立即收官(`task_id`):跟踪职责移交 task", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.600Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Agent\",\n \"state\": \"done\",\n \"input\": {\n \"description\": \"审查 LoginView 的白屏修复\",\n \"agent_type\": \"reviewer\",\n \"run_in_background\": true\n },\n \"output\": {\n \"task_id\": \"task_01\"\n },\n \"agent_refs\": [\n {\n \"agent_id\": \"review_01\",\n \"role\": \"child\"\n }\n ]\n}" + }, + { + "note": "`task` 出现并接管(`kind: subagent`、`detached: true`)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.610Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"subagent\",\n \"state\": \"running\",\n \"detached\": true,\n \"description\": \"审查 LoginView 的白屏修复\",\n \"child_agent_id\": \"review_01\",\n \"output_tail\": \"\",\n \"started_at\": \"2026-09-03T17:30:01.610Z\"\n}" + }, + { + "note": "主 agent 不受阻,直接继续", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:02.000Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:02.000Z\",\n \"message_id\": \"t1.0.a1\",\n \"text\": \"子代理在后台审查,完成后我汇总。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:02.300Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"子代理在后台审查,完成后我汇总。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:02.300Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:00.022Z\",\n \"ended_at\": \"2026-09-03T17:30:02.300Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:02.400Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:30:00.015Z\",\n \"ended_at\": \"2026-09-03T17:30:02.400Z\",\n \"usage\": {\n \"input_tokens\": 2600,\n \"output_tokens\": 64\n },\n \"duration_ms\": 2383,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:02.400Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"白屏修好了,帮我审查一下改动\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T17:30:00.000Z\",\n \"finished_at\": \"2026-09-03T17:30:02.400Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:02.420Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4500,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "几分钟后台进度——`output_tail` 末尾窗口整体重发", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:03.500Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"subagent\",\n \"state\": \"running\",\n \"detached\": true,\n \"description\": \"审查 LoginView 的白屏修复\",\n \"child_agent_id\": \"review_01\",\n \"output_tail\": \"…正在读 LoginView 的改动…\",\n \"started_at\": \"2026-09-03T17:30:01.610Z\"\n}" + }, + { + "note": "task 到终态(`result_summary` 带审查结论)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.100Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"subagent\",\n \"state\": \"completed\",\n \"detached\": true,\n \"description\": \"审查 LoginView 的白屏修复\",\n \"child_agent_id\": \"review_01\",\n \"output_tail\": \"审查通过:可选链修复正确,无回归风险。\",\n \"started_at\": \"2026-09-03T17:30:01.610Z\",\n \"ended_at\": \"2026-09-03T17:30:04.100Z\",\n \"result_summary\": \"审查通过,无回归风险\"\n}" + }, + { + "note": "结果回收:`origin: task` 的新 turn(例 12 同款)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.110Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T17:30:04.110Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:04.120Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788456604110\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4500,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.130Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:04.130Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.500Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.500Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"后台审查完成了:修复正确,无回归风险,可以放心提交。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.800Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"后台审查完成了:修复正确,无回归风险,可以放心提交。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.800Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:04.130Z\",\n \"ended_at\": \"2026-09-03T17:30:04.800Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 58,\n \"input_cache_read\": 10600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.900Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T17:30:04.110Z\",\n \"ended_at\": \"2026-09-03T17:30:04.900Z\",\n \"usage\": {\n \"input_tokens\": 2800,\n \"output_tokens\": 58\n },\n \"duration_ms\": 787\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:04.910Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5400,\n \"output\": 122,\n \"input_cache_read\": 20400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4800,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 后台 = tool_call 立即 done(task_id)+ task 接管:主 agent 不受阻,t1 当场收官
· 结果回收两跳:task completedresult_summary)→ origin: task 新 turn 汇报——与例 12 后台命令完全同款", + "branch_from": "" + }, + { + "label": "C · 前台转后台(主通道)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"白屏修好了,帮我审查一下改动\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T17:30:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:30:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788456600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"先前台跑着,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:00.580Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"不行再转后台。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先前台跑着,不行再转后台。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我起一个审查子代理:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我起一个审查子代理:\"\n}" + }, + { + "note": "前台开始——主 step 阻塞中", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Agent\",\n \"state\": \"running\",\n \"input\": {\n \"description\": \"审查 LoginView 的白屏修复\",\n \"agent_type\": \"reviewer\"\n },\n \"agent_refs\": [\n {\n \"agent_id\": \"review_01\",\n \"role\": \"child\"\n }\n ]\n}" + }, + { + "note": "用户在等待时点了「转后台」(REST `POST /sessions/s_16/tool_calls/call_01:detach`,不在 WS)。`task` 补发,`started_at` 沿用 tool_call 起点——任务时长连续", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:03.500Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"subagent\",\n \"state\": \"running\",\n \"detached\": true,\n \"description\": \"审查 LoginView 的白屏修复\",\n \"child_agent_id\": \"review_01\",\n \"output_tail\": \"…正在读 LoginView 的改动…\",\n \"started_at\": \"2026-09-03T17:30:03.500Z\"\n}" + }, + { + "note": "tool_call 以 `detached` 收官,主 step 解阻", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:03.510Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Agent\",\n \"state\": \"done\",\n \"input\": {\n \"description\": \"审查 LoginView 的白屏修复\",\n \"agent_type\": \"reviewer\"\n },\n \"output\": {\n \"detached\": true,\n \"task_id\": \"task_01\"\n },\n \"agent_refs\": [\n {\n \"agent_id\": \"review_01\",\n \"role\": \"child\"\n }\n ]\n}" + }, + { + "note": "主 agent 不受阻,直接继续", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:03.800Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:03.800Z\",\n \"message_id\": \"t1.0.a1\",\n \"text\": \"子代理转后台了,完成后我汇总。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.100Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"子代理转后台了,完成后我汇总。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:00.022Z\",\n \"ended_at\": \"2026-09-03T17:30:04.100Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.200Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:30:00.015Z\",\n \"ended_at\": \"2026-09-03T17:30:04.200Z\",\n \"usage\": {\n \"input_tokens\": 2600,\n \"output_tokens\": 64\n },\n \"duration_ms\": 4183,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:04.200Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"白屏修好了,帮我审查一下改动\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T17:30:00.000Z\",\n \"finished_at\": \"2026-09-03T17:30:04.200Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:04.220Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4500,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "几分钟后台进度——`output_tail` 末尾窗口整体重发", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:05.300Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"subagent\",\n \"state\": \"running\",\n \"detached\": true,\n \"description\": \"审查 LoginView 的白屏修复\",\n \"child_agent_id\": \"review_01\",\n \"output_tail\": \"…正在读 LoginView 的改动…\",\n \"started_at\": \"2026-09-03T17:30:03.500Z\"\n}" + }, + { + "note": "task 到终态(`result_summary` 带审查结论)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:05.900Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"subagent\",\n \"state\": \"completed\",\n \"detached\": true,\n \"description\": \"审查 LoginView 的白屏修复\",\n \"child_agent_id\": \"review_01\",\n \"output_tail\": \"审查通过:可选链修复正确,无回归风险。\",\n \"started_at\": \"2026-09-03T17:30:03.500Z\",\n \"ended_at\": \"2026-09-03T17:30:05.900Z\",\n \"result_summary\": \"审查通过,无回归风险\"\n}" + }, + { + "note": "结果回收:`origin: task` 的新 turn(例 12 同款)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:05.910Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T17:30:05.910Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:05.920Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788456605910\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4500,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:05.930Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:05.930Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:06.300Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:06.300Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"后台审查完成了:修复正确,无回归风险,可以放心提交。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:06.600Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"后台审查完成了:修复正确,无回归风险,可以放心提交。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:06.600Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:05.930Z\",\n \"ended_at\": \"2026-09-03T17:30:06.600Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 58,\n \"input_cache_read\": 10600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:30:06.700Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T17:30:05.910Z\",\n \"ended_at\": \"2026-09-03T17:30:06.700Z\",\n \"usage\": {\n \"input_tokens\": 2800,\n \"output_tokens\": 58\n },\n \"duration_ms\": 787\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_16\",\n \"timestamp\": \"2026-09-03T17:30:06.710Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5400,\n \"output\": 122,\n \"input_cache_read\": 20400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4800,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 转后台是运行时转换:tool_call 以 detached + task_id 收官,task 补发且 started_at 沿用 tool_call 起点
· 触发者可以是用户手动(本例),也可以是系统超时阈值——与例 12 的 Bash 转后台同一机制", + "branch_from": "" + }, + { + "label": "子代理通道(按需订阅)", + "items": [ + { + "note": "用户在任一模式下点开子代理面板——客户端按需订阅子通道(C→S)", + "json": "{\n \"type\": \"subscribe\",\n \"id\": 2,\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\"\n}" + }, + { + "note": "确认(S→C)", + "json": "{\n \"type\": \"ack\",\n \"id\": 2,\n \"code\": 0\n}" + }, + { + "note": "子代理的完整消息流从此下来——与主通道同型同规则(以 B 模式为例)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:01.600Z\",\n \"turn_id\": \"r1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T17:30:01.600Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:01.620Z\",\n \"step_id\": \"r1.0\",\n \"turn_id\": \"r1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:30:01.620Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:02.000Z\",\n \"message_id\": \"r1.0.h0\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:02.000Z\",\n \"message_id\": \"r1.0.h0\",\n \"text\": \"先读 LoginView 的改动,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:02.200Z\",\n \"message_id\": \"r1.0.h0\",\n \"text\": \"重点看 token 处理。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:02.800Z\",\n \"message_id\": \"r1.0.h0\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"status\": \"completed\",\n \"text\": \"先读 LoginView 的改动,重点看 token 处理。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:02.800Z\",\n \"message_id\": \"r1.0.a0\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:02.800Z\",\n \"message_id\": \"r1.0.a0\",\n \"text\": \"我先读 LoginView 的改动。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:03.200Z\",\n \"message_id\": \"r1.0.a0\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"status\": \"completed\",\n \"text\": \"我先读 LoginView 的改动。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:03.200Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"name\": \"Read\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:03.800Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"name\": \"Read\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\"\n },\n \"output\": {\n \"content\": \"\",\n \"lines\": 214\n }\n}" + }, + { + "note": "审查结论(这条正文即主通道 `task.result_summary` / 前台 `output.report` 的来源)", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:04.500Z\",\n \"message_id\": \"r1.0.a1\",\n \"turn_id\": \"r1\",\n \"step_id\": \"r1.0\",\n \"status\": \"completed\",\n \"text\": \"审查通过:可选链修复正确,无回归风险。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:04.500Z\",\n \"step_id\": \"r1.0\",\n \"turn_id\": \"r1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:30:01.620Z\",\n \"ended_at\": \"2026-09-03T17:30:04.500Z\",\n \"usage\": {\n \"input_other\": 2200,\n \"output\": 64,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_16\",\n \"agent_id\": \"review_01\",\n \"timestamp\": \"2026-09-03T17:30:04.600Z\",\n \"turn_id\": \"r1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"started_at\": \"2026-09-03T17:30:01.600Z\",\n \"ended_at\": \"2026-09-03T17:30:04.600Z\",\n \"usage\": {\n \"input_tokens\": 2200,\n \"output_tokens\": 64\n },\n \"duration_ms\": 2997\n}" + } + ], + "tail": "要点
· 子通道消息与主通道**同型同规则**:同一套 handleMessage,按 agent_id 落到不同 store / 面板;子代理的 turn 用 origin: task 注明来历
· 按需 = 不订阅就没有流量:N 个并行子代理时主通道依然干净;desktop 的 main-only 天然成立(主通道本来就只有 main)
· 子通道也有 §9 恢复:订阅时在流 step 回放 + 状态实体重发,与主通道一字不差", + "branch_from": "" + } + ] + }, + { + "id": "sidechat", + "title": "侧聊(sideChat)", + "desc": "主对话刚完成,用户在侧边栏问个快速问题:side agent 的一轮完整小对话,与主对话互不干扰。会话 s_17。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "主对话 t1(修白屏)刚完成。用户在侧边栏开侧聊——sideChat 的消息与主 agent 同型,`agent_id` 区分(`side_01` vs `main`);侧聊的用户消息", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:00.010Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"`user?.token` 是啥语法?\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T17:40:00.000Z\"\n}" + }, + { + "note": "`origin: side` 标明来源", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:00.015Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"side\"\n },\n \"started_at\": \"2026-09-03T17:40:00.015Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "侧聊不碰主对话状态:`main_turn_active` 仍为 false", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_17\",\n \"timestamp\": \"2026-09-03T17:40:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788457200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5500,\n \"output\": 162,\n \"input_cache_read\": 19500,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5700,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:00.022Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:40:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:00.500Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:00.500Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"`user?.token` 是可选链:`user` 为 null / undefined 时整个表达式短路为 undefined,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:00.700Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"不会再抛 TypeError。昨天的白屏正是缺了它。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:01.000Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"`user?.token` 是可选链:`user` 为 null / undefined 时整个表达式短路为 undefined,不会再抛 TypeError。昨天的白屏正是缺了它。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:01.000Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:40:00.022Z\",\n \"ended_at\": \"2026-09-03T17:40:01.000Z\",\n \"usage\": {\n \"input_other\": 2100,\n \"output\": 48,\n \"input_cache_read\": 6000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:01.100Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"side\"\n },\n \"started_at\": \"2026-09-03T17:40:00.015Z\",\n \"ended_at\": \"2026-09-03T17:40:01.100Z\",\n \"usage\": {\n \"input_tokens\": 2100,\n \"output_tokens\": 48\n },\n \"duration_ms\": 1083,\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_17\",\n \"agent_id\": \"side_01\",\n \"timestamp\": \"2026-09-03T17:40:01.100Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"`user?.token` 是啥语法?\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T17:40:00.000Z\",\n \"finished_at\": \"2026-09-03T17:40:01.100Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_17\",\n \"timestamp\": \"2026-09-03T17:40:01.120Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 7600,\n \"output\": 210,\n \"input_cache_read\": 25500,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5900,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· sideChat 与主对话**同型同规则**(prompt / user / turn / step / assistant 一套不少),agent_id 区分、origin: side 标明来源
· main_turn_active 始终 false:侧聊不影响主对话的 turn / step / 审批,两个频道在同一 session 序列里按序下发" + } + ] + }, + { + "id": "plan", + "title": "plan 模式", + "desc": "agent 调 EnterPlanMode 进模式出方案(plan.revision 定稿),ExitPlanMode 提交计划走审批,批准后实施。会话 s_18。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1;用户消息进时间线", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把登录页改造成支持 SSO\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T17:50:00.000Z\"\n}" + }, + { + "note": "turn 开始", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:50:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788457800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "step 开始,先思考", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:50:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"改造涉及多个文件,先进入 plan 模式出方案。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"改造涉及多个文件,先进入 plan 模式出方案。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"这个改造涉及多个文件,我先出方案再动手:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"这个改造涉及多个文件,我先出方案再动手:\"\n}" + }, + { + "note": "agent 调 `EnterPlanMode`——进出 plan 模式都是 tool_call(与 TodoWrite/todo 同一模式)", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"EnterPlanMode\",\n \"state\": \"running\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.600Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"EnterPlanMode\",\n \"state\": \"done\",\n \"output\": {\n \"entered\": true\n }\n}" + }, + { + "note": "时间线上留下进入标记", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:01.610Z\",\n \"payload\": {\n \"mode\": \"plan\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"plan.enter\"\n}" + }, + { + "note": "聚合快照:`modes.plan` 标出模式与计划版本", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:01.620Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788457800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2200,\n \"output\": 58,\n \"input_cache_read\": 8000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4100,\n \"max_context_tokens\": 262144,\n \"modes\": {\n \"plan\": {\n \"version\": 0\n }\n }\n}" + }, + { + "note": "计划正文就是普通 assistant 消息", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.000Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.000Z\",\n \"message_id\": \"t1.0.a1\",\n \"text\": \"SSO 改造分四步:1. 接入 OAuth 客户端(`auth/oauth.ts\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.300Z\",\n \"message_id\": \"t1.0.a1\",\n \"text\": \"`);2. 登录页加 SSO 按钮;3. 新增 `/callback` 路由处理回跳;4. 本地会话与 SSO 会话合并。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.700Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"SSO 改造分四步:1. 接入 OAuth 客户端(`auth/oauth.ts`);2. 登录页加 SSO 按钮;3. 新增 `/callback` 路由处理回跳;4. 本地会话与 SSO 会话合并。\"\n}" + }, + { + "note": "计划定稿第一版——`plan.revision` 版本标记(`key` 指向计划文档)", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.700Z\",\n \"payload\": {\n \"version\": 1,\n \"key\": \"plans/sso.md\",\n \"summary\": \"SSO 改造四步\"\n },\n \"system_id\": \"m_02\",\n \"subtype\": \"plan.revision\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:02.710Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788457800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4300,\n \"max_context_tokens\": 262144,\n \"modes\": {\n \"plan\": {\n \"version\": 1,\n \"review_path\": \"plans/sso.md\"\n }\n }\n}" + }, + { + "note": "方案就绪,调 `ExitPlanMode` 提交计划等批准", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.900Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"ExitPlanMode\",\n \"state\": \"running\",\n \"input\": {\n \"plan_key\": \"plans/sso.md\"\n },\n \"display\": {\n \"kind\": \"plan_review\",\n \"plan\": \"## 目标\\n\\n接入 SSO 登录,改造涉及多个文件。\",\n \"path\": \"plans/sso.md\"\n }\n}" + }, + { + "note": "提交计划 = 一次审批:用户审方案", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.910Z\",\n \"interaction_id\": \"ap_01\",\n \"kind\": \"approval\",\n \"state\": \"pending\",\n \"tool_call_id\": \"call_02\",\n \"request\": {\n \"tool_name\": \"ExitPlanMode\",\n \"input\": {\n \"plan_key\": \"plans/sso.md\"\n },\n \"reason\": \"请审查并批准实施方案\",\n \"display\": {\n \"kind\": \"plan_review\",\n \"plan\": \"## 目标\\n\\n接入 SSO 登录,改造涉及多个文件。\",\n \"path\": \"plans/sso.md\"\n }\n }\n}" + }, + { + "note": "`approval_id` 回链(提交计划 = 一次审批)", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:02.910Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"ExitPlanMode\",\n \"state\": \"running\",\n \"input\": {\n \"plan_key\": \"plans/sso.md\"\n },\n \"display\": {\n \"kind\": \"plan_review\",\n \"plan\": \"## 目标\\n\\n接入 SSO 登录,改造涉及多个文件。\",\n \"path\": \"plans/sso.md\"\n },\n \"approval_id\": \"ap_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:02.920Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"approval\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"awaiting_approval\",\n \"turn_id\": 0,\n \"step\": 0,\n \"since\": 1788457802910\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4300,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "用户点「允许,按计划执行」(REST `POST /sessions/s_18/approvals/ap_01:decide`,不在 WS)", + "json": "{\n \"type\": \"interaction\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.000Z\",\n \"interaction_id\": \"ap_01\",\n \"kind\": \"approval\",\n \"state\": \"approved\",\n \"tool_call_id\": \"call_02\",\n \"request\": {\n \"tool_name\": \"ExitPlanMode\",\n \"input\": {\n \"plan_key\": \"plans/sso.md\"\n },\n \"reason\": \"请审查并批准实施方案\",\n \"display\": {\n \"kind\": \"plan_review\",\n \"plan\": \"## 目标\\n\\n接入 SSO 登录,改造涉及多个文件。\",\n \"path\": \"plans/sso.md\"\n }\n },\n \"response\": {\n \"decision\": \"approved\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:05.010Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788457800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"current_turn\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n },\n \"total\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4300,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.020Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"ExitPlanMode\",\n \"state\": \"done\",\n \"input\": {\n \"plan_key\": \"plans/sso.md\"\n },\n \"output\": {\n \"approved\": true\n },\n \"display\": {\n \"kind\": \"plan_review\",\n \"plan\": \"## 目标\\n\\n接入 SSO 登录,改造涉及多个文件。\",\n \"path\": \"plans/sso.md\"\n },\n \"approval_id\": \"ap_01\"\n}" + }, + { + "note": "退出标记出模式", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.030Z\",\n \"payload\": {\n \"approved\": true,\n \"version\": 1,\n \"key\": \"plans/sso.md\"\n },\n \"system_id\": \"m_03\",\n \"subtype\": \"plan.exit\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:05.040Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788457800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4300,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "开始按方案实施", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.400Z\",\n \"message_id\": \"t1.0.a2\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.400Z\",\n \"message_id\": \"t1.0.a2\",\n \"text\": \"开始实施,先接 OAuth 客户端:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.800Z\",\n \"message_id\": \"t1.0.a2\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"开始实施,先接 OAuth 客户端:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:05.800Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Write\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/auth/oauth.ts\",\n \"content\": \"…(OAuth 客户端封装)…\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:06.300Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Write\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/auth/oauth.ts\",\n \"content\": \"…(OAuth 客户端封装)…\"\n },\n \"output\": {\n \"bytes_written\": 612\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:06.400Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:50:00.022Z\",\n \"ended_at\": \"2026-09-03T17:50:06.400Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 96,\n \"input_cache_read\": 8600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:06.450Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T17:50:06.450Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:06.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:06.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"第 1 步完成。继续第 2 步(登录页 SSO 按钮)?\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:07.100Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"第 1 步完成。继续第 2 步(登录页 SSO 按钮)?\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:07.100Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T17:50:06.450Z\",\n \"ended_at\": \"2026-09-03T17:50:07.100Z\",\n \"usage\": {\n \"input_other\": 2900,\n \"output\": 74,\n \"input_cache_read\": 11200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:07.200Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T17:50:00.015Z\",\n \"ended_at\": \"2026-09-03T17:50:07.200Z\",\n \"usage\": {\n \"input_tokens\": 5300,\n \"output_tokens\": 170\n },\n \"duration_ms\": 7183,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_18\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T17:50:07.200Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把登录页改造成支持 SSO\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T17:50:00.000Z\",\n \"finished_at\": \"2026-09-03T17:50:07.200Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_18\",\n \"timestamp\": \"2026-09-03T17:50:07.220Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5300,\n \"output\": 170,\n \"input_cache_read\": 19800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5600,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 进出 plan 模式都是 tool_call:EnterPlanMode(agent 主动进入)与 ExitPlanMode(提交计划等批准)——tool_call 是动作,plan.enter/exit system 是时间线标记(与 TodoWrite/todo 同一模式)
· ExitPlanMode 走审批:interaction(approval) pending → approved,用户批准方案后才 plan.exit 进入实施
· 计划正文就是普通 assistant 消息;plan.revision 是版本标记(key 指向计划文档);模式状态挂 session.state.modes.planversion / review_path)" + } + ] + }, + { + "id": "goal", + "title": "goal(目标模式)", + "desc": "用户设立带达标条件的目标:goal 挂 session.state,agent 自治执行到达成(status: complete + marker)。会话 s_19。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户通过 goal 模式下达目标(REST `POST /sessions/s_19/goal`,不在 WS)。goal 挂在 `session.state.goal`——全量重发即设立", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_19\",\n \"timestamp\": \"2026-09-03T18:00:00.000Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788458400015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144,\n \"goal\": {\n \"objective\": \"修复登录页白屏并让登录测试全绿\",\n \"status\": \"active\",\n \"completion_criterion\": \"pnpm test -- login 全部通过\",\n \"budget_used\": 0,\n \"budget_limit\": 50\n }\n}" + }, + { + "note": "goal 驱动的 turn 起跑(`origin: goal`)", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"goal\"\n },\n \"started_at\": \"2026-09-03T18:00:00.015Z\",\n \"user_message_id\": \"p_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:00:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"先复现定位,再修,最后跑测试验证达标条件。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先复现定位,再修,最后跑测试验证达标条件。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"先修复崩溃点:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先修复崩溃点:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:02.000Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:02.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:00:00.022Z\",\n \"ended_at\": \"2026-09-03T18:00:02.100Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:02.150Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:00:02.150Z\"\n}" + }, + { + "note": "验证达标条件", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:02.300Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:04.300Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 passed (1)\\n Tests 6 passed (6)\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:04.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:04.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"目标达成:白屏已修复,登录相关 6 个测试全部通过。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:04.950Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"目标达成:白屏已修复,登录相关 6 个测试全部通过。\"\n}" + }, + { + "note": "达标——`goal.status: complete`(budget 记 3 个 step)", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_19\",\n \"timestamp\": \"2026-09-03T18:00:04.950Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 1,\n \"step_id\": \"t1.1\",\n \"since\": 1788458400015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5300,\n \"output\": 140,\n \"input_cache_read\": 18600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5400,\n \"max_context_tokens\": 262144,\n \"goal\": {\n \"objective\": \"修复登录页白屏并让登录测试全绿\",\n \"status\": \"complete\",\n \"completion_criterion\": \"pnpm test -- login 全部通过\",\n \"budget_used\": 3,\n \"budget_limit\": 50\n }\n}" + }, + { + "note": "goal 达成的标记", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:04.960Z\",\n \"payload\": {\n \"status\": \"complete\",\n \"objective\": \"修复登录页白屏并让登录测试全绿\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"goal\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:05.000Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:00:02.150Z\",\n \"ended_at\": \"2026-09-03T18:00:05.000Z\",\n \"usage\": {\n \"input_other\": 2700,\n \"output\": 76,\n \"input_cache_read\": 10200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_19\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:00:05.100Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"goal\"\n },\n \"started_at\": \"2026-09-03T18:00:00.015Z\",\n \"ended_at\": \"2026-09-03T18:00:05.100Z\",\n \"usage\": {\n \"input_tokens\": 5300,\n \"output_tokens\": 140\n },\n \"duration_ms\": 5083,\n \"user_message_id\": \"p_01\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_19\",\n \"timestamp\": \"2026-09-03T18:00:05.110Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5300,\n \"output\": 140,\n \"input_cache_read\": 18600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5400,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· goal 挂在 session.state.goalobjective / status / completion_criterion / budget_*):设立、推进、达成都经全量重发呈现
· goal 驱动的 turn 用 origin: goal 注明来源;达成时加一条 system(goal) 作为时间线标记
· goal 通常驱动多 turn 自治执行,本例压缩为一个 turn 演示消息形态" + } + ] + }, + { + "id": "undo", + "title": "undo / 回滚", + "desc": "用户 undo 已完成的一轮:marker(undo) 追加到时间线,客户端按 marker 截断渲染;历史只增不改。会话 s_20。", + "sections": [ + { + "label": "undo(WS)", + "items": [ + { + "note": "前置:t1「把配置里的超时改成 30 秒」已完成(Edit 已落盘)——用户改主意了。用户执行 undo(REST `POST /sessions/s_20/undo { \"turn_id\": \"t1\" }`,不在 WS)。时间线上追加一条 `system(undo)`", + "json": "{\n \"type\": \"system\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:05.000Z\",\n \"payload\": {\n \"undo_turn_id\": \"t1\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"undo\"\n}" + }, + { + "note": "聚合快照照常重发(usage 不回滚——钱已经花了)", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_20\",\n \"timestamp\": \"2026-09-03T18:10:05.010Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5100,\n \"output\": 128,\n \"input_cache_read\": 17600,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5000,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· undo = system(undo) + 客户端按 system(undo) **截断本地时间线**(§16 对齐语义):t1 不再渲染,但历史不被改写
· 多端一致:REST 历史仍返回 t1 与 system(undo)(见下节),任何客户端按同一条 system 截断,渲染结果相同" + }, + { + "label": "REST 历史", + "items": [ + { + "note": "undo 后刷新页面,`GET /api/v1/sessions/s_20/history?page_size=50`——t1 全部实体与 `system(undo)` 都在;客户端渲染到 system(undo) 时把 t1 从视图截掉", + "json": "{\n \"session_id\": \"s_20\",\n \"items\": [\n {\n \"type\": \"turn\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:04.600Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:10:00.012Z\",\n \"ended_at\": \"2026-09-03T18:10:04.595Z\",\n \"usage\": {\n \"input_tokens\": 5100,\n \"output_tokens\": 128\n },\n \"duration_ms\": 4583,\n \"user_message_id\": \"t1.u0\"\n },\n {\n \"type\": \"step\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:04.500Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:10:00.020Z\",\n \"ended_at\": \"2026-09-03T18:10:04.495Z\",\n \"usage\": {\n \"input_other\": 5100,\n \"output\": 128,\n \"input_cache_read\": 17600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n },\n {\n \"type\": \"user\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把配置里的超时改成 30 秒\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T18:10:00.000Z\",\n \"finished_at\": \"2026-09-03T18:10:04.595Z\"\n },\n {\n \"type\": \"thinking\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:00.700Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"超时配置在 config/server.toml,直接 Edit。\"\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:01.200Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来改超时配置:\"\n },\n {\n \"type\": \"tool_call\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:01.800Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"request_timeout = 10\",\n \"new\": \"request_timeout = 30\"\n },\n \"output\": {\n \"applied\": true\n }\n },\n {\n \"type\": \"assistant\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:04.400Z\",\n \"message_id\": \"t1.0.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"改好了:`request_timeout` 已从 10 改为 30。\"\n },\n {\n \"type\": \"system\",\n \"session_id\": \"s_20\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:10:05.000Z\",\n \"payload\": {\n \"undo_turn_id\": \"t1\"\n },\n \"system_id\": \"m_01\",\n \"subtype\": \"undo\"\n }\n ],\n \"has_more\": false,\n \"in_flight\": null\n}" + } + ], + "request": "GET /api/v1/sessions/s_20/history?page_size=50", + "chip": "REST", + "tail": "要点
· 历史是**只增不改**的:undo 追加标记而非删除;截断是纯客户端渲染语义——这也是多客户端/多 tab 能对齐的唯一办法" + } + ] + }, + { + "id": "attachment", + "title": "附件(attachment)", + "desc": "用户发报错截图:附件走 REST 上传/下载,消息里只有 attachment_ids 引用。会话 s_21。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "附件先经 REST 上传(`POST /attachments` → `att_01`,不在 WS);随后提交 prompt;user 消息带 `attachment_ids` 引用(内容不进 WS)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"看下这个报错截图\",\n \"attachment_ids\": [\n \"att_01\"\n ],\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T18:20:00.000Z\"\n}" + }, + { + "note": "turn 封面同样带 `attachment_ids`", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:20:00.015Z\",\n \"attachment_ids\": [\n \"att_01\"\n ],\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_21\",\n \"timestamp\": \"2026-09-03T18:20:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788459600015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:20:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"截图里是 TypeError,位置在 handleLogin:87。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"截图里是 TypeError,位置在 handleLogin:87。\"\n}" + }, + { + "note": "正文输出", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"截图显示 `TypeError: Cannot read properties of undefined (reading 'token')`,\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.250Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"发生在 `LoginView.vue:87` 的 `handleLogin`——`user` 为空时直接读了 `token`。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.600Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"截图显示 `TypeError: Cannot read properties of undefined (reading 'token')`,发生在 `LoginView.vue:87` 的 `handleLogin`——`user` 为空时直接读了 `token`。\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.600Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:20:00.022Z\",\n \"ended_at\": \"2026-09-03T18:20:01.600Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 66,\n \"input_cache_read\": 9000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.700Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:20:00.015Z\",\n \"ended_at\": \"2026-09-03T18:20:01.700Z\",\n \"usage\": {\n \"input_tokens\": 2600,\n \"output_tokens\": 66\n },\n \"duration_ms\": 1683,\n \"attachment_ids\": [\n \"att_01\"\n ],\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_21\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:20:01.700Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"看下这个报错截图\",\n \"attachment_ids\": [\n \"att_01\"\n ],\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T18:20:00.000Z\",\n \"finished_at\": \"2026-09-03T18:20:01.700Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_21\",\n \"timestamp\": \"2026-09-03T18:20:01.720Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 2600,\n \"output\": 66,\n \"input_cache_read\": 9000,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4500,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 附件经 REST 上传拿 id(POST /attachments),WS 消息里只有 attachment_ids 引用——二进制不进消息流
· userturn 都带 attachment_ids:时间线气泡和 turn 封面各自需要;下载同样走 REST(GET /attachments/{id})" + } + ] + }, + { + "id": "big-output", + "title": "大 output 截断", + "desc": "全量测试输出 4821 行:tool_call 内联只带尾部窗口 + truncated + total_lines,完整内容经 REST 拉取。会话 s_22。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下全量测试\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T18:30:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:30:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_22\",\n \"timestamp\": \"2026-09-03T18:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788460200015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:30:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"全量测试输出会很大,截断内联即可。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"全量测试输出会很大,截断内联即可。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"我来跑全量测试:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"我来跑全量测试:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test --all\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool.progress\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:20.000Z\",\n \"tool_call_id\": \"call_01\",\n \"progress\": {\n \"kind\": \"stdout\",\n \"text\": \"… running 43 test files …\"\n }\n}" + }, + { + "note": "输出 4821 行——内联只带尾部窗口 + `truncated` + `total_lines`;完整内容经 REST `GET /api/v1/sessions/s_22/tool_calls/call_01/output` 拉取", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:45.200Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test --all\"\n },\n \"output\": {\n \"stdout\": \"…\\n ✓ packages/app-core/src/lib/wire.test.ts (42 tests)\\n ✗ packages/app-client/src/stores/chat.test.ts (2 failed)\\nTest Files 2 failed | 41 passed (43)\\n Tests 2 failed | 386 passed (388)\\n\",\n \"exit_code\": 1,\n \"truncated\": true,\n \"total_lines\": 4821\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:45.300Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:30:00.022Z\",\n \"ended_at\": \"2026-09-03T18:30:45.300Z\",\n \"usage\": {\n \"input_other\": 2800,\n \"output\": 72,\n \"input_cache_read\": 11000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "带结果再调 LLM", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:45.350Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:30:45.350Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:45.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:45.700Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"全量 43 个测试文件:41 通过、2 失败(都在 `app-client` 的 chat store)。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:45.800Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"完整日志 4821 行,需要我拉出来定位吗?\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:46.000Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"全量 43 个测试文件:41 通过、2 失败(都在 `app-client` 的 chat store)。完整日志 4821 行,需要我拉出来定位吗?\"\n}" + }, + { + "note": "step / turn / prompt 依次收官,会话回到空闲", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:46.000Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:30:45.350Z\",\n \"ended_at\": \"2026-09-03T18:30:46.000Z\",\n \"usage\": {\n \"input_other\": 3000,\n \"output\": 80,\n \"input_cache_read\": 12100,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:46.100Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:30:00.015Z\",\n \"ended_at\": \"2026-09-03T18:30:46.100Z\",\n \"usage\": {\n \"input_tokens\": 5800,\n \"output_tokens\": 152\n },\n \"duration_ms\": 46083,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_22\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:30:46.100Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"跑一下全量测试\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T18:30:00.000Z\",\n \"finished_at\": \"2026-09-03T18:30:46.100Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_22\",\n \"timestamp\": \"2026-09-03T18:30:46.120Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5800,\n \"output\": 152,\n \"input_cache_read\": 23100,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 5900,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· 大 output 内联截断:truncated: true + total_lines,内联为**尾部窗口**;完整内容走 REST 按 tool_call 拉取——与「大实体通知 + REST」(例 15)同一条规则
· task.output_tail(例 12)与 truncated output 是同一个「末尾窗口」惯例:WS 上永远只有窗口,全量永远在 REST" + } + ] + }, + { + "id": "cron", + "title": "cron 触发", + "desc": "定时任务到点:cron 指令以 user { origin: { kind: \"cron\" } } 呈现,origin: cron 的 turn 自动执行并汇报。会话 s_23。", + "sections": [ + { + "label": "流式(WS)", + "items": [ + { + "note": "定时任务到点——cron 指令以 `user { origin: { kind: \"cron\" } }` 呈现(用户亲手写的调度指令,不是系统标注)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:00.000Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T18:40:00.000Z\",\n \"text\": \"跑一遍登录相关测试并汇报结果\",\n \"origin\": {\n \"kind\": \"cron\",\n \"cron_id\": \"cron_01\",\n \"schedule\": \"0 9 * * 1-5\"\n }\n}" + }, + { + "note": "cron 触发的 turn:`origin: cron` + `user_message_id` 关联那条 cron 消息", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"cron\",\n \"cron_id\": \"cron_01\"\n },\n \"started_at\": \"2026-09-03T18:40:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_23\",\n \"timestamp\": \"2026-09-03T18:40:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788460800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:40:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"定时任务:跑登录测试并汇报。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"定时任务:跑登录测试并汇报。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"开始执行定时任务:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"开始执行定时任务:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:03.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 passed (1)\\n Tests 6 passed (6)\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:03.600Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:40:00.022Z\",\n \"ended_at\": \"2026-09-03T18:40:03.600Z\",\n \"usage\": {\n \"input_other\": 2400,\n \"output\": 58,\n \"input_cache_read\": 9000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:03.650Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:40:03.650Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:04.000Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:04.000Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"定时报告:登录相关 6 个测试全部通过,无异常。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:04.300Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"定时报告:登录相关 6 个测试全部通过,无异常。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:04.300Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:40:03.650Z\",\n \"ended_at\": \"2026-09-03T18:40:04.300Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 62,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:04.400Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"cron\",\n \"cron_id\": \"cron_01\"\n },\n \"started_at\": \"2026-09-03T18:40:00.015Z\",\n \"ended_at\": \"2026-09-03T18:40:04.400Z\",\n \"usage\": {\n \"input_tokens\": 5000,\n \"output_tokens\": 120\n },\n \"duration_ms\": 4383,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "cron 指令被消费完(`status: completed`)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_23\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:40:04.400Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T18:40:00.000Z\",\n \"finished_at\": \"2026-09-03T18:40:04.400Z\",\n \"text\": \"跑一遍登录相关测试并汇报结果\",\n \"origin\": {\n \"kind\": \"cron\",\n \"cron_id\": \"cron_01\",\n \"schedule\": \"0 9 * * 1-5\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_23\",\n \"timestamp\": \"2026-09-03T18:40:04.410Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5000,\n \"output\": 120,\n \"input_cache_read\": 18800,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 4800,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· cron 触发 = user { origin: { kind: \"cron\", cron_id, schedule } } + origin: cron 的 turn:指令是用户亲手写的调度内容,以特殊 user 消息呈现(原 marker(cron.fired) 已迁移);turn 经 user_message_id 关联
· 与 origin: task(例 12)、origin: side(例 18)、origin: goal(例 20)同一模式:非普通用户触发的 turn,封面注明来源" + } + ] + }, + { + "id": "injection", + "title": "注入(steer / cron / task · 忙时)", + "desc": "事件在 turn 进行中到达:steer 用户追发(running + steered_at)、cron 忙时到点(marker 原位)、后台 task 忙时完成(照常下发)——都不开新 turn,agent 在当前 turn 吸收。会话 s_24。", + "sections": [ + { + "label": "steer(WS)", + "items": [ + { + "note": "用户提交 prompt(REST,不在 WS)。随后 WS 上——起跑序列同例 1", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:00.010Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把登录页白屏修一下\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T18:50:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:00.015Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:50:00.015Z\",\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_24\",\n \"timestamp\": \"2026-09-03T18:50:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 0,\n \"step\": 0,\n \"step_id\": \"t1.0\",\n \"since\": 1788461400015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 0,\n \"output\": 0,\n \"input_cache_read\": 0,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 1820,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:00.022Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:50:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:00.450Z\",\n \"message_id\": \"t1.0.h0\",\n \"text\": \"白屏是 user 为空读 token 导致,加可选链。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:01.050Z\",\n \"message_id\": \"t1.0.h0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"白屏是 user 为空读 token 导致,加可选链。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:01.050Z\",\n \"message_id\": \"t1.0.a0\",\n \"text\": \"先修复崩溃点:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:01.500Z\",\n \"message_id\": \"t1.0.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"status\": \"completed\",\n \"text\": \"先修复崩溃点:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:02.000Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/views/LoginView.vue\",\n \"old\": \"const token = user.token;\",\n \"new\": \"const token = user?.token;\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:02.100Z\",\n \"step_id\": \"t1.0\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:50:00.022Z\",\n \"ended_at\": \"2026-09-03T18:50:02.100Z\",\n \"usage\": {\n \"input_other\": 2600,\n \"output\": 64,\n \"input_cache_read\": 9800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:02.150Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-03T18:50:02.150Z\"\n}" + }, + { + "note": "跑测试验证", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:02.300Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n }\n}" + }, + { + "note": "测试跑着的时候,用户追发一条 steer——`running + steered_at`:注入即生效,不排队、不开新 turn(对照例 7 的排队);steer 的 user 消息挂当前 turn(`turn_id: t1`)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:03.010Z\",\n \"message_id\": \"t1.u1\",\n \"turn_id\": \"t1\",\n \"text\": \"顺便把超时时间也改成 30\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-03T18:50:03.000Z\",\n \"steered_at\": \"2026-09-03T18:50:03.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:04.500Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 passed (1)\\n Tests 6 passed (6)\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "agent 在同一 turn 里吸收 steer:先报测试结果,接着办 steer 的事", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:04.900Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:04.900Z\",\n \"message_id\": \"t1.1.a0\",\n \"text\": \"测试通过,白屏修好了。接着把超时改成 30:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:05.300Z\",\n \"message_id\": \"t1.1.a0\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"测试通过,白屏修好了。接着把超时改成 30:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:05.300Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"request_timeout = 10\",\n \"new\": \"request_timeout = 30\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:05.700Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"config/server.toml\",\n \"old\": \"request_timeout = 10\",\n \"new\": \"request_timeout = 30\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.100Z\",\n \"message_id\": \"t1.1.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.100Z\",\n \"message_id\": \"t1.1.a1\",\n \"text\": \"都完成了:白屏已修复(测试全绿),超时已改为 30 秒。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.400Z\",\n \"message_id\": \"t1.1.a1\",\n \"turn_id\": \"t1\",\n \"step_id\": \"t1.1\",\n \"status\": \"completed\",\n \"text\": \"都完成了:白屏已修复(测试全绿),超时已改为 30 秒。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.400Z\",\n \"step_id\": \"t1.1\",\n \"turn_id\": \"t1\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-03T18:50:02.150Z\",\n \"ended_at\": \"2026-09-03T18:50:06.400Z\",\n \"usage\": {\n \"input_other\": 3200,\n \"output\": 118,\n \"input_cache_read\": 13600,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.500Z\",\n \"turn_id\": \"t1\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-03T18:50:00.015Z\",\n \"ended_at\": \"2026-09-03T18:50:06.500Z\",\n \"usage\": {\n \"input_tokens\": 5800,\n \"output_tokens\": 182\n },\n \"duration_ms\": 6483,\n \"user_message_id\": \"t1.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.500Z\",\n \"message_id\": \"t1.u0\",\n \"turn_id\": \"t1\",\n \"text\": \"把登录页白屏修一下\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T18:50:00.000Z\",\n \"finished_at\": \"2026-09-03T18:50:06.500Z\"\n}" + }, + { + "note": "steer 消息随当前 turn 收官(status: completed)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-03T18:50:06.500Z\",\n \"message_id\": \"t1.u1\",\n \"turn_id\": \"t1\",\n \"text\": \"顺便把超时时间也改成 30\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-03T18:50:03.000Z\",\n \"steered_at\": \"2026-09-03T18:50:03.000Z\",\n \"finished_at\": \"2026-09-03T18:50:06.500Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_24\",\n \"timestamp\": \"2026-09-03T18:50:06.530Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5800,\n \"output\": 182,\n \"input_cache_read\": 23400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 6100,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· steer = user 消息挂当前 turn + status: running + steered_at:注入即生效,不开新 turn、不打断当前流式;随当前 turn 收官(status: completed
· 对照例 7 排队(running 但尚无 turn → 等接棒开新 turn):两种追发策略在提交时由客户端选择,wire 上的区别是「尚无 turn」vs「挂当前 turn + steered_at」" + }, + { + "label": "cron · 忙时(WS)", + "items": [ + { + "note": "次日:t2「重构 auth 模块」进行中(大活)。起跑序列同前", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:50.010Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"重构 auth 模块\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-04T08:59:50.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:50.015Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-04T08:59:50.015Z\",\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_24\",\n \"timestamp\": \"2026-09-04T08:59:50.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 1,\n \"step\": 0,\n \"step_id\": \"t2.0\",\n \"since\": 1788512390015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 5800,\n \"output\": 182,\n \"input_cache_read\": 23400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 6100,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:50.022Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-04T08:59:50.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:50.450Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:50.450Z\",\n \"message_id\": \"t2.0.h0\",\n \"text\": \"先拆 session 签发逻辑。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:51.050Z\",\n \"message_id\": \"t2.0.h0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"先拆 session 签发逻辑。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:51.050Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:51.050Z\",\n \"message_id\": \"t2.0.a0\",\n \"text\": \"先拆 session 签发:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:51.500Z\",\n \"message_id\": \"t2.0.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"status\": \"completed\",\n \"text\": \"先拆 session 签发:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T08:59:51.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/auth/session.ts\",\n \"old\": \"…(旧签发逻辑)…\",\n \"new\": \"…(拆分后)…\"\n }\n}" + }, + { + "note": "09:00 整,定时任务到点——t2 正在流:`user { origin: { kind: \"cron\" } }` 挂当前 turn + `steered_at`(系统发起的 steer),不开新 turn", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:00.000Z\",\n \"message_id\": \"t2.u1\",\n \"turn_id\": \"t2\",\n \"status\": \"running\",\n \"steered_at\": \"2026-09-04T09:00:00.000Z\",\n \"created_at\": \"2026-09-04T09:00:00.000Z\",\n \"text\": \"跑一遍登录相关测试并汇报结果\",\n \"origin\": {\n \"kind\": \"cron\",\n \"cron_id\": \"cron_01\",\n \"schedule\": \"0 9 * * 1-5\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:00.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/auth/session.ts\",\n \"old\": \"…(旧签发逻辑)…\",\n \"new\": \"…(拆分后)…\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:00.600Z\",\n \"step_id\": \"t2.0\",\n \"turn_id\": \"t2\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-04T08:59:50.022Z\",\n \"ended_at\": \"2026-09-04T09:00:00.600Z\",\n \"usage\": {\n \"input_other\": 3400,\n \"output\": 96,\n \"input_cache_read\": 15000,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"tool_use\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:00.650Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"running\",\n \"started_at\": \"2026-09-04T09:00:00.650Z\"\n}" + }, + { + "note": "agent 安排顺序:先把手头重构收完,再执行 cron 指令", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:01.000Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:01.000Z\",\n \"message_id\": \"t2.1.a0\",\n \"text\": \"定时任务到点了。先把重构收尾:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:01.400Z\",\n \"message_id\": \"t2.1.a0\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"completed\",\n \"text\": \"定时任务到点了。先把重构收尾:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:01.400Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/auth/index.ts\",\n \"old\": \"export * from './session';\",\n \"new\": \"export * from './session';\\nexport * from './token';\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:01.800Z\",\n \"tool_call_id\": \"call_02\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/auth/index.ts\",\n \"old\": \"export * from './session';\",\n \"new\": \"export * from './session';\\nexport * from './token';\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "手头事毕,执行 cron 指令", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:02.200Z\",\n \"message_id\": \"t2.1.a1\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:02.200Z\",\n \"message_id\": \"t2.1.a1\",\n \"text\": \"重构完成。现在执行定时任务——跑登录测试:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:02.600Z\",\n \"message_id\": \"t2.1.a1\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"completed\",\n \"text\": \"重构完成。现在执行定时任务——跑登录测试:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:02.600Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"name\": \"Bash\",\n \"state\": \"running\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:04.600Z\",\n \"tool_call_id\": \"call_03\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"name\": \"Bash\",\n \"state\": \"done\",\n \"input\": {\n \"command\": \"pnpm test -- login\"\n },\n \"output\": {\n \"stdout\": \"Test Files 1 passed (1)\\n Tests 6 passed (6)\\n\",\n \"exit_code\": 0\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.000Z\",\n \"message_id\": \"t2.1.a2\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.000Z\",\n \"message_id\": \"t2.1.a2\",\n \"text\": \"都完成了:auth 重构收尾;定时任务已执行——登录测试 6/6 通过。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.300Z\",\n \"message_id\": \"t2.1.a2\",\n \"turn_id\": \"t2\",\n \"step_id\": \"t2.1\",\n \"status\": \"completed\",\n \"text\": \"都完成了:auth 重构收尾;定时任务已执行——登录测试 6/6 通过。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.300Z\",\n \"step_id\": \"t2.1\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-04T09:00:00.650Z\",\n \"ended_at\": \"2026-09-04T09:00:05.300Z\",\n \"usage\": {\n \"input_other\": 3600,\n \"output\": 142,\n \"input_cache_read\": 16200,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.400Z\",\n \"turn_id\": \"t2\",\n \"ordinal\": 1,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-04T08:59:50.015Z\",\n \"ended_at\": \"2026-09-04T09:00:05.400Z\",\n \"usage\": {\n \"input_tokens\": 7000,\n \"output_tokens\": 238\n },\n \"duration_ms\": 15383,\n \"user_message_id\": \"t2.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.400Z\",\n \"message_id\": \"t2.u1\",\n \"turn_id\": \"t2\",\n \"status\": \"completed\",\n \"steered_at\": \"2026-09-04T09:00:00.000Z\",\n \"created_at\": \"2026-09-04T09:00:00.000Z\",\n \"finished_at\": \"2026-09-04T09:00:05.400Z\",\n \"text\": \"跑一遍登录相关测试并汇报结果\",\n \"origin\": {\n \"kind\": \"cron\",\n \"cron_id\": \"cron_01\",\n \"schedule\": \"0 9 * * 1-5\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T09:00:05.400Z\",\n \"message_id\": \"t2.u0\",\n \"turn_id\": \"t2\",\n \"text\": \"重构 auth 模块\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-04T08:59:50.000Z\",\n \"finished_at\": \"2026-09-04T09:00:05.400Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_24\",\n \"timestamp\": \"2026-09-04T09:00:05.420Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 12800,\n \"output\": 420,\n \"input_cache_read\": 38400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 10400,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· cron 忙时到点 = user { origin: { kind: \"cron\" } } 挂当前 turn + steered_at——系统发起的 steer,与用户 steer 同一条路径(对照例 24 idle 时开 origin: cron turn);session.state 不变(main_turn_active 仍 true)
· 吸收顺序由 agent 安排(先收手头的 step、再执行 cron 指令)——协议只负责把消息送到,不干预调度", + "branch_from": "" + }, + { + "label": "task 完成 · 忙时(WS)", + "items": [ + { + "note": "t3「把登录页按钮改成品牌色」进行中;昨天的后台构建 task_01 还在跑。起跑序列同前", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:00.010Z\",\n \"message_id\": \"t3.u0\",\n \"turn_id\": \"t3\",\n \"text\": \"把登录页按钮改成品牌色\",\n \"status\": \"running\",\n \"created_at\": \"2026-09-04T10:30:00.000Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:00.015Z\",\n \"turn_id\": \"t3\",\n \"ordinal\": 2,\n \"state\": \"running\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-04T10:30:00.015Z\",\n \"user_message_id\": \"t3.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_24\",\n \"timestamp\": \"2026-09-04T10:30:00.020Z\",\n \"busy\": true,\n \"main_turn_active\": true,\n \"pending_interaction\": \"none\",\n \"activity\": \"turn\",\n \"phase\": {\n \"kind\": \"running\",\n \"turn_id\": 2,\n \"step\": 0,\n \"step_id\": \"t3.0\",\n \"since\": 1788517800015\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 12800,\n \"output\": 420,\n \"input_cache_read\": 38400,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 10400,\n \"max_context_tokens\": 262144\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:00.022Z\",\n \"step_id\": \"t3.0\",\n \"turn_id\": \"t3\",\n \"ordinal\": 0,\n \"state\": \"running\",\n \"started_at\": \"2026-09-04T10:30:00.022Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:00.450Z\",\n \"message_id\": \"t3.0.h0\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:00.450Z\",\n \"message_id\": \"t3.0.h0\",\n \"text\": \"改按钮样式,一处 CSS 变量即可。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"thinking\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:01.050Z\",\n \"message_id\": \"t3.0.h0\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"status\": \"completed\",\n \"text\": \"改按钮样式,一处 CSS 变量即可。\"\n}" + }, + { + "note": "过渡正文", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:01.050Z\",\n \"message_id\": \"t3.0.a0\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:01.050Z\",\n \"message_id\": \"t3.0.a0\",\n \"text\": \"我来改按钮颜色:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:01.500Z\",\n \"message_id\": \"t3.0.a0\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"status\": \"completed\",\n \"text\": \"我来改按钮颜色:\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:01.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"name\": \"Edit\",\n \"state\": \"running\",\n \"input\": {\n \"path\": \"apps/web/src/styles/theme.css\",\n \"old\": \"--btn-primary: #6b7280;\",\n \"new\": \"--btn-primary: #4f46e5;\"\n }\n}" + }, + { + "note": "后台构建此刻完成——`task completed` 照常下发(它本来就在主通道),不开 `origin: task` 新 turn(对照例 12 的 idle 汇报)", + "json": "{\n \"type\": \"task\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:02.000Z\",\n \"task_id\": \"task_01\",\n \"kind\": \"shell\",\n \"state\": \"completed\",\n \"detached\": true,\n \"description\": \"pnpm build\",\n \"output_tail\": \"… build finished successfully …\",\n \"started_at\": \"2026-09-04T10:30:02.000Z\",\n \"ended_at\": \"2026-09-04T10:30:02.000Z\",\n \"result_summary\": \"构建成功:8 个包全部编译通过\"\n}" + }, + { + "note": "后台任务完成通知 = 特殊 user 消息(origin task + 结构化 notification)", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:02.010Z\",\n \"message_id\": \"t3.u1\",\n \"turn_id\": \"t3\",\n \"text\": \"后台任务完成\\n构建成功:8 个包全部编译通过\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-04T10:30:02.010Z\",\n \"finished_at\": \"2026-09-04T10:30:02.010Z\",\n \"origin\": {\n \"kind\": \"task\",\n \"task_id\": \"task_01\"\n },\n \"notification\": {\n \"title\": \"后台任务完成\",\n \"body\": \"构建成功:8 个包全部编译通过\",\n \"severity\": \"info\",\n \"type\": \"task.completed\",\n \"source_kind\": \"background_task\",\n \"source_id\": \"task_01\"\n }\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"tool_call\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:02.500Z\",\n \"tool_call_id\": \"call_01\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"name\": \"Edit\",\n \"state\": \"done\",\n \"input\": {\n \"path\": \"apps/web/src/styles/theme.css\",\n \"old\": \"--btn-primary: #6b7280;\",\n \"new\": \"--btn-primary: #4f46e5;\"\n },\n \"output\": {\n \"applied\": true\n }\n}" + }, + { + "note": "agent 在当前 turn 自然吸收结果", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:02.900Z\",\n \"message_id\": \"t3.0.a1\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"status\": \"streaming\",\n \"text\": \"\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant.delta\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:02.900Z\",\n \"message_id\": \"t3.0.a1\",\n \"text\": \"按钮颜色改好了。顺便说,后台构建也完成了:8 个包全部编译通过。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"assistant\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:03.200Z\",\n \"message_id\": \"t3.0.a1\",\n \"turn_id\": \"t3\",\n \"step_id\": \"t3.0\",\n \"status\": \"completed\",\n \"text\": \"按钮颜色改好了。顺便说,后台构建也完成了:8 个包全部编译通过。\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"step\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:03.200Z\",\n \"step_id\": \"t3.0\",\n \"turn_id\": \"t3\",\n \"ordinal\": 0,\n \"state\": \"completed\",\n \"started_at\": \"2026-09-04T10:30:00.022Z\",\n \"ended_at\": \"2026-09-04T10:30:03.200Z\",\n \"usage\": {\n \"input_other\": 3100,\n \"output\": 92,\n \"input_cache_read\": 12800,\n \"input_cache_creation\": 0\n },\n \"finish_reason\": \"end_turn\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"turn\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:03.300Z\",\n \"turn_id\": \"t3\",\n \"ordinal\": 2,\n \"state\": \"completed\",\n \"origin\": {\n \"kind\": \"user\"\n },\n \"started_at\": \"2026-09-04T10:30:00.015Z\",\n \"ended_at\": \"2026-09-04T10:30:03.300Z\",\n \"usage\": {\n \"input_tokens\": 3100,\n \"output_tokens\": 92\n },\n \"duration_ms\": 3283,\n \"user_message_id\": \"t3.u0\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"user\",\n \"session_id\": \"s_24\",\n \"agent_id\": \"main\",\n \"timestamp\": \"2026-09-04T10:30:03.300Z\",\n \"message_id\": \"t3.u0\",\n \"turn_id\": \"t3\",\n \"text\": \"把登录页按钮改成品牌色\",\n \"status\": \"completed\",\n \"created_at\": \"2026-09-04T10:30:00.000Z\",\n \"finished_at\": \"2026-09-04T10:30:03.300Z\"\n}" + }, + { + "note": "", + "json": "{\n \"type\": \"session.state\",\n \"session_id\": \"s_24\",\n \"timestamp\": \"2026-09-04T10:30:03.320Z\",\n \"busy\": false,\n \"main_turn_active\": false,\n \"pending_interaction\": \"none\",\n \"activity\": \"idle\",\n \"phase\": {\n \"kind\": \"idle\"\n },\n \"model\": \"kimi-k3-highspeed\",\n \"permission\": \"manual\",\n \"usage\": {\n \"total\": {\n \"input_other\": 15900,\n \"output\": 512,\n \"input_cache_read\": 51200,\n \"input_cache_creation\": 0\n }\n },\n \"context_tokens\": 11800,\n \"max_context_tokens\": 262144\n}" + } + ], + "tail": "要点
· task 完成在忙时:completed 实体照常下发(它本来就在主通道),不需要任何额外消息;不开 origin: task 新 turn
· 结果回收路径只看到达时会话忙闲:idle 开新 turn(例 12),busy 注入当前 turn(本例)——实体消息本身一字不差", + "branch_from": "" + } + ] + } + ] +} diff --git a/packages/kap-server/test/v2Engine.e2e.test.ts b/packages/kap-server/test/v2Engine.e2e.test.ts new file mode 100644 index 00000000000..7d43c763de0 --- /dev/null +++ b/packages/kap-server/test/v2Engine.e2e.test.ts @@ -0,0 +1,544 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { WebSocket, type RawData } from 'ws'; + +import { + IProtocolAdapterRegistry, + ProtocolAdapterRegistry, + type IProtocolAdapterRegistry as IProtocolAdapterRegistryType, + type ProtocolAdapterConfig, + type StreamedMessagePart, + type TokenUsage, +} from '@moonshot-ai/agent-core-v2'; +import { startServer, type RunningServer } from '../src'; +import { TEST_HOST_IDENTITY } from './helpers/hostIdentity'; + +interface ScriptedResponse { + readonly parts: readonly StreamedMessagePart[]; + readonly finishReason?: string | null; + readonly delays?: readonly number[]; +} + +const ZERO_USAGE: TokenUsage = { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }; + +function sleepAbort(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(timer); + reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')); + }); + }); +} + +class ScriptedStream { + id: string | null = null; + usage: TokenUsage | null = null; + finishReason: string | null = null; + rawFinishReason: string | null = null; + + constructor( + private readonly response: ScriptedResponse, + private readonly signal?: AbortSignal, + ) {} + + async *[Symbol.asyncIterator](): AsyncIterator { + for (let i = 0; i < this.response.parts.length; i++) { + const delay = this.response.delays?.[i] ?? 0; + if (delay > 0) await sleepAbort(delay, this.signal); + yield this.response.parts[i]!; + } + const hasToolCall = this.response.parts.some((p) => p.type === 'function'); + this.id = 'scripted'; + this.usage = { ...ZERO_USAGE, output: this.response.parts.length }; + this.finishReason = this.response.finishReason ?? (hasToolCall ? 'tool_calls' : 'completed'); + this.rawFinishReason = this.finishReason === 'completed' ? 'stop' : this.finishReason; + } +} + +class ScriptedChatProvider { + readonly name = 'scripted'; + readonly modelName = 'scripted'; + readonly thinkingEffort = null; + + constructor(private readonly queue: ScriptedResponse[]) {} + + async generate(_systemPrompt?: unknown, _tools?: unknown, _history?: unknown, options?: { signal?: AbortSignal }): Promise { + const response = this.queue.shift(); + if (response === undefined) throw new Error('scriptedProvider: queue exhausted'); + return new ScriptedStream(response, options?.signal); + } + + withThinking(): this { + return this; + } + + withMaxCompletionTokens(): this { + return this; + } +} + +function createScriptedSeed(): { + readonly seed: readonly [typeof IProtocolAdapterRegistry, IProtocolAdapterRegistryType]; + readonly push: (response: ScriptedResponse) => void; +} { + const queue: ScriptedResponse[] = []; + const provider = new ScriptedChatProvider(queue); + const real = new ProtocolAdapterRegistry(); + const registry = { + _serviceBrand: undefined, + supportedProtocols: () => real.supportedProtocols(), + resolveAdapterIdentity: real.resolveAdapterIdentity.bind(real), + resolveProviderBaseId: real.resolveProviderBaseId.bind(real), + resolveCapability: real.resolveCapability.bind(real), + explainCapability: real.explainCapability.bind(real), + createChatProvider: (_input: ProtocolAdapterConfig) => provider, + } as unknown as IProtocolAdapterRegistryType; + return { + seed: [IProtocolAdapterRegistry, registry], + push: (response) => queue.push(response), + }; +} + +interface V2Conn { + ws: WebSocket; + frames: Record[]; + next(predicate: (frame: Record) => boolean, timeoutMs?: number): Promise>; + send(frame: unknown): void; + close(): Promise; +} + +function rawToString(data: RawData): string { + if (typeof data === 'string') return data; + if (Buffer.isBuffer(data)) return data.toString('utf8'); + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); + return Buffer.from(data).toString('utf8'); +} + +function openV2(port: number): Promise { + const frames: Record[] = []; + const waiters: { predicate: (frame: Record) => boolean; resolve: (frame: Record) => void; timer: ReturnType }[] = []; + const ws = new WebSocket(`ws://127.0.0.1:${port}/api/v2/ws`, ['kimi-code.bearer.smoke']); + const conn: V2Conn = { + ws, + frames, + next: (predicate, timeoutMs = 30_000) => + new Promise((resolve, reject) => { + const hit = frames.find(predicate); + if (hit !== undefined) { + resolve(hit); + return; + } + const waiter = { + predicate, + resolve, + timer: setTimeout(() => { + reject(new Error('v2 frame wait timeout')); + }, timeoutMs), + }; + waiters.push(waiter); + }), + send: (frame) => { + ws.send(JSON.stringify(frame)); + }, + close: () => + new Promise((resolve) => { + if (ws.readyState === WebSocket.CLOSED) { + resolve(); + return; + } + ws.on('close', () => { + resolve(); + }); + ws.close(); + }), + }; + ws.on('message', (data) => { + const frame = JSON.parse(rawToString(data)) as Record; + frames.push(frame); + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i]!; + if (waiter.predicate(frame)) { + clearTimeout(waiter.timer); + waiters.splice(i, 1); + waiter.resolve(frame); + } + } + }); + return new Promise((resolve, reject) => { + ws.on('open', () => { + resolve(conn); + }); + ws.on('error', reject); + }); +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +async function postJson(port: number, path: string, body?: unknown): Promise { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return (await res.json()) as Promise; +} + +async function getJson(port: number, path: string): Promise { + const res = await fetch(`http://127.0.0.1:${port}${path}`); + return (await res.json()) as Promise; +} + +interface SessionWire { + id: string; +} + +function spine(frames: readonly Record[], types: readonly string[]): boolean { + let cursor = 0; + for (const type of types) { + let found = -1; + for (let i = cursor; i < frames.length; i++) { + if (frames[i]!['type'] === type) { + found = i; + break; + } + } + if (found < 0) return false; + cursor = found + 1; + } + return true; +} + +describe('v2 stack 真实引擎 e2e', () => { + let server: RunningServer; + let scripted: ReturnType; + let port: number; + let homeDir: string; + let wsDir: string; + + beforeAll(async () => { + homeDir = mkdtempSync(join(tmpdir(), 'kimi-v2-engine-home-')); + wsDir = mkdtempSync(join(tmpdir(), 'kimi-v2-engine-ws-')); + writeFileSync( + join(homeDir, 'config.toml'), + [ + 'default_model = "stub"', + '', + '[providers.stub]', + 'type = "openai"', + 'base_url = "http://127.0.0.1:9999"', + 'api_key = "stub"', + '', + '[models.stub]', + 'provider = "stub"', + 'model = "stub"', + 'max_context_size = 1000000', + '', + ].join('\n'), + ); + scripted = createScriptedSeed(); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir, + logLevel: 'silent', + disableAuth: true, + seeds: [scripted.seed], + }); + port = server.port; + }, 120_000); + + afterAll(async () => { + await server?.close(); + rmSync(homeDir, { recursive: true, force: true }); + rmSync(wsDir, { recursive: true, force: true }); + }, 60_000); + + it('(a) 纯文本 turn:basic 结构', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + const ack = await conn.next((f) => f['type'] === 'ack'); + expect(ack).toMatchObject({ id: 1, code: 0 }); + + scripted.push({ parts: [{ type: 'think', think: '在打招呼' }, { type: 'text', text: '你好!' }, { type: 'text', text: '有什么可以帮你的?' }] }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '你好' }] }); + await conn.next((f) => f['type'] === 'turn' && f['state'] === 'completed'); + await conn.next((f) => f['type'] === 'user' && f['status'] === 'completed'); + const idle = await conn.next((f) => f['type'] === 'session.state' && f['busy'] === false); + + const frames = conn.frames; + expect(spine(frames, ['hello', 'ack', 'user', 'turn', 'session.state', 'step', 'thinking', 'thinking.delta', 'assistant', 'assistant.delta', 'assistant', 'step', 'turn', 'user', 'session.state'])).toBe(true); + const turn = frames.find((f) => f['type'] === 'turn' && f['state'] === 'running')!; + expect(turn).toMatchObject({ turn_id: 't1', ordinal: 0, user_message_id: 't1.u0', origin: { kind: 'user' } }); + const step = frames.find((f) => f['type'] === 'step')!; + expect(step).toMatchObject({ step_id: 't1.0', turn_id: 't1' }); + const completed = frames.find((f) => f['type'] === 'assistant' && f['status'] === 'completed')!; + expect(completed).toMatchObject({ message_id: 't1.0.a0', turn_id: 't1', step_id: 't1.0' }); + expect(String(completed['text'])).toBe('你好!有什么可以帮你的?'); + expect(idle).toMatchObject({ busy: false, main_turn_active: false, activity: 'idle' }); + await conn.close(); + }, 60_000); + + it('(b) Bash 工具 turn(yolo 免审批):tool 结构', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub', permission_mode: 'yolo' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + await conn.next((f) => f['type'] === 'ack'); + + scripted.push({ parts: [{ type: 'function', id: 'call_b1', name: 'Bash', arguments: '{"command":"echo hello_from_bash"}' }] }); + scripted.push({ parts: [{ type: 'text', text: '已经跑完:输出 hello_from_bash。' }] }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '跑一下 echo' }] }); + await conn.next((f) => f['type'] === 'turn' && f['state'] === 'completed'); + await conn.next((f) => f['type'] === 'session.state' && f['busy'] === false); + + const frames = conn.frames; + expect(spine(frames, ['hello', 'ack', 'user', 'turn', 'step', 'tool_call', 'tool_call', 'step', 'step', 'assistant', 'step', 'turn', 'user'])).toBe(true); + const callRunning = frames.find((f) => f['type'] === 'tool_call' && f['state'] === 'running')!; + expect(callRunning).toMatchObject({ name: 'Bash', state: 'running', input: { command: 'echo hello_from_bash' } }); + const callDone = frames.find((f) => f['type'] === 'tool_call' && f['state'] === 'done')!; + expect(callDone).toMatchObject({ name: 'Bash', state: 'done' }); + expect(JSON.stringify(callDone['output'])).toContain('hello_from_bash'); + const steps = frames.filter((f) => f['type'] === 'step' && f['state'] === 'completed'); + expect(steps.map((s) => s['finish_reason'])).toEqual(['tool_use', 'end_turn']); + await conn.close(); + }, 60_000); + + it('(c) manual 权限 approval:interaction 全链路', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub', permission_mode: 'manual' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + await conn.next((f) => f['type'] === 'ack'); + + scripted.push({ parts: [{ type: 'function', id: 'call_c1', name: 'Bash', arguments: '{"command":"echo needs_approval"}' }] }); + scripted.push({ parts: [{ type: 'text', text: '批准后已执行:输出 needs_approval。' }] }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '执行需要审批的命令' }] }); + const pending = await conn.next((f) => f['type'] === 'interaction' && f['state'] === 'pending'); + expect(pending).toMatchObject({ kind: 'approval', state: 'pending', tool_call_id: 'call_c1' }); + const approvalId = pending['interaction_id'] as string; + expect(approvalId).toBeTruthy(); + const backlink = await conn.next((f) => f['type'] === 'tool_call' && f['approval_id'] !== undefined); + expect(backlink).toMatchObject({ approval_id: approvalId, state: 'running' }); + const awaiting = await conn.next((f) => f['type'] === 'session.state' && f['pending_interaction'] === 'approval'); + expect(awaiting).toMatchObject({ pending_interaction: 'approval', phase: { kind: 'awaiting_approval' } }); + + await postJson(port, `/api/v1/sessions/${sessionId}/approvals/${approvalId}`, { decision: 'approved' }); + const resolved = await conn.next((f) => f['type'] === 'interaction' && f['state'] === 'approved'); + expect(resolved).toMatchObject({ state: 'approved', response: { decision: 'approved' }, request: pending['request'] }); + await conn.next((f) => f['type'] === 'tool_call' && f['state'] === 'done'); + await conn.next((f) => f['type'] === 'turn' && f['state'] === 'completed'); + await conn.next((f) => f['type'] === 'session.state' && f['busy'] === false); + + const frames = conn.frames; + expect(spine(frames, ['interaction', 'tool_call', 'session.state', 'interaction', 'session.state', 'tool_call', 'step', 'turn', 'user'])).toBe(true); + await conn.close(); + }, 60_000); + + it('(d) TodoWrite:todo 实体与回链', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + await conn.next((f) => f['type'] === 'ack'); + + const todos1 = [ + { title: '定位白屏原因', status: 'in_progress' }, + { title: '修复崩溃', status: 'pending' }, + ]; + const todos2 = [ + { title: '定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'in_progress' }, + ]; + scripted.push({ parts: [{ type: 'function', id: 'call_d1', name: 'TodoList', arguments: JSON.stringify({ todos: todos1 }) }] }); + scripted.push({ parts: [{ type: 'function', id: 'call_d2', name: 'TodoList', arguments: JSON.stringify({ todos: todos2 }) }] }); + scripted.push({ parts: [{ type: 'text', text: '清单已更新,开始修复。' }] }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '先建任务清单再修' }] }); + await conn.next((f) => f['type'] === 'turn' && f['state'] === 'completed'); + await conn.next((f) => f['type'] === 'session.state' && f['busy'] === false); + + const frames = conn.frames; + const todoFrames = frames.filter((f) => f['type'] === 'todo'); + expect(todoFrames.length).toBe(2); + const todoIds = new Set(todoFrames.map((f) => f['todo_id'])); + expect(todoIds.size).toBe(1); + const todoId = [...todoIds][0]; + expect(todoId).toMatch(/^td_\d{2}$/); + expect(todoFrames[0]).toMatchObject({ todo_id: todoId, items: todos1 }); + expect(todoFrames[1]).toMatchObject({ todo_id: todoId, items: todos2 }); + const todoCalls = frames.filter((f) => f['type'] === 'tool_call' && f['name'] === 'TodoWrite'); + expect(todoCalls.length).toBeGreaterThanOrEqual(2); + for (const call of todoCalls) expect(call['todo_id']).toBe(todoId); + expect(spine(frames, ['tool_call', 'todo', 'tool_call', 'todo', 'assistant', 'step', 'turn', 'user'])).toBe(true); + await conn.close(); + }, 60_000); + + it('(e) 中断:abort 结构与 system interruption', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + await conn.next((f) => f['type'] === 'ack'); + + scripted.push({ + parts: [ + { type: 'text', text: '先列顶层目录,' }, + { type: 'text', text: '再按功能分组说明。' }, + ], + delays: [0, 60_000], + finishReason: 'completed', + }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '总结一下这个项目的目录结构' }] }); + await conn.next((f) => f['type'] === 'assistant.delta'); + await sleep(300); + await postJson(port, `/api/v1/sessions/${sessionId}:abort`); + const interrupted = await conn.next((f) => f['type'] === 'step' && f['state'] === 'interrupted', 20_000); + const turnEnd = await conn.next((f) => f['type'] === 'turn' && f['state'] === 'completed', 20_000); + const system = await conn.next((f) => f['type'] === 'system' && f['subtype'] === 'interruption'); + const userDone = await conn.next((f) => f['type'] === 'user' && f['status'] === 'completed'); + await conn.next((f) => f['type'] === 'session.state' && f['busy'] === false); + + const frames = conn.frames; + expect(interrupted).toMatchObject({ step_id: 't1.0', state: 'interrupted' }); + expect(turnEnd).toMatchObject({ turn_id: 't1', state: 'completed' }); + expect(system).toMatchObject({ subtype: 'interruption', system_id: 'm_01', payload: { reason: 'aborted', turn_id: 't1' } }); + expect(userDone).toMatchObject({ message_id: 't1.u0', status: 'completed' }); + expect(spine(frames, ['step', 'turn', 'user', 'system'])).toBe(true); + await conn.close(); + }, 90_000); + + it('(f) 刷新恢复:断线重连恢复载荷', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + await conn.next((f) => f['type'] === 'ack'); + + scripted.push({ + parts: [ + { type: 'text', text: '建议加在入口的' }, + { type: 'text', text: '全局参数解析处:' }, + { type: 'text', text: '`src/cli.ts` 里注册 `--verbose`。' }, + { type: 'text', text: '日志模块读到标志后调到 debug。' }, + ], + delays: [0, 0, 2500, 0], + }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?' }] }); + await conn.next((f) => f['type'] === 'assistant.delta', 20_000); + await sleep(200); + await conn.close(); + await sleep(600); + + const second = await openV2(port); + second.send({ type: 'subscribe', id: 1, session_id: sessionId }); + const ack2 = await second.next((f) => f['type'] === 'ack'); + expect(ack2).toMatchObject({ id: 1, code: 0 }); + const recoveryTurn = await second.next((f) => f['type'] === 'turn' && f['state'] === 'running'); + const recoveryStep = await second.next((f) => f['type'] === 'step' && f['state'] === 'running'); + const recoveryText = await second.next((f) => f['type'] === 'assistant' && f['status'] === 'streaming'); + const recoveryState = await second.next((f) => f['type'] === 'session.state'); + + expect(recoveryTurn).toMatchObject({ turn_id: 't1', state: 'running', user_message_id: 't1.u0' }); + expect(recoveryStep).toMatchObject({ step_id: 't1.0', state: 'running', turn_id: 't1' }); + expect(recoveryText).toMatchObject({ message_id: 't1.0.a0', status: 'streaming' }); + expect(String(recoveryText['text'])).toBe('建议加在入口的全局参数解析处:'); + expect(recoveryState).toMatchObject({ busy: true, main_turn_active: true, activity: 'turn', phase: { kind: 'running' } }); + + const finalText = await second.next((f) => f['type'] === 'assistant' && f['status'] === 'completed', 60_000); + expect(String(finalText['text'])).toContain('建议加在入口的全局参数解析处:'); + await second.next((f) => f['type'] === 'turn' && f['state'] === 'completed', 60_000); + await second.next((f) => f['type'] === 'session.state' && f['busy'] === false, 20_000); + await second.close(); + }, 120_000); + + it('(g) REST 历史:turn 完成后冷重建结构', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub', permission_mode: 'yolo' } }); + const conn = await openV2(port); + conn.send({ type: 'subscribe', id: 1, session_id: sessionId }); + await conn.next((f) => f['type'] === 'ack'); + scripted.push({ parts: [{ type: 'function', id: 'call_g1', name: 'Bash', arguments: '{"command":"ls"}' }] }); + scripted.push({ parts: [{ type: 'text', text: '当前目录内容如上。' }] }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: '执行一下 ls' }] }); + await conn.next((f) => f['type'] === 'turn' && f['state'] === 'completed'); + await conn.next((f) => f['type'] === 'user' && f['status'] === 'completed'); + await sleep(500); + + const page = await getJson<{ data: { session_id: string; items: Record[]; has_more: boolean; in_flight: unknown } }>( + port, + `/api/v1/sessions/${sessionId}/history?page_size=50`, + ); + expect(page.data.has_more).toBe(false); + expect(page.data.in_flight).toBeNull(); + const items = page.data.items; + expect(items.map((item) => item['type'])).toEqual(['turn', 'step', 'user', 'tool_call', 'step', 'assistant']); + expect(items[0]).toMatchObject({ type: 'turn', turn_id: 't1', state: 'completed', user_message_id: 't1.u0' }); + expect(items[0]!['usage']).toBeDefined(); + expect(items[0]!['duration_ms']).toBeDefined(); + expect(items[1]).toMatchObject({ type: 'step', step_id: 't1.0', state: 'completed', finish_reason: 'tool_use' }); + expect(items[2]).toMatchObject({ type: 'user', message_id: 't1.u0', status: 'completed' }); + expect(items[3]).toMatchObject({ type: 'tool_call', tool_call_id: 'call_g1', name: 'Bash', state: 'done' }); + expect(items[5]).toMatchObject({ type: 'assistant', message_id: 't1.1.a0', status: 'completed' }); + await conn.close(); + }, 60_000); + + it('(h) v1 并存冒烟:v1 subscribe 正常收帧', async () => { + const created = await postJson<{ data: SessionWire }>(port, '/api/v1/sessions', { metadata: { cwd: wsDir } }); + const sessionId = created.data.id; + await postJson(port, `/api/v1/sessions/${sessionId}/profile`, { agent_config: { model: 'stub' } }); + const ws = new WebSocket(`ws://127.0.0.1:${port}/api/v1/ws`, ['kimi-code.bearer.smoke']); + const frames: Record[] = []; + ws.on('message', (data) => { + frames.push(JSON.parse(rawToString(data)) as Record); + }); + await new Promise((resolve, reject) => { + ws.on('open', resolve); + ws.on('error', reject); + }); + ws.send(JSON.stringify({ type: 'client_hello', id: 'h1', payload: { client_id: 'p5-smoke', subscriptions: [sessionId] } })); + const ack = await new Promise>((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('v1 ack timeout')); + }, 10_000); + const poll = setInterval(() => { + const hit = frames.find((f) => f['type'] === 'ack'); + if (hit !== undefined) { + clearInterval(poll); + clearTimeout(timer); + resolve(hit); + } + }, 25); + }); + expect(ack).toMatchObject({ id: 'h1' }); + + scripted.push({ parts: [{ type: 'text', text: 'v1 也能收到这条。' }] }); + await postJson(port, `/api/v1/sessions/${sessionId}/prompts`, { content: [{ type: 'text', text: 'v1 冒烟' }] }); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('v1 event timeout')); + }, 15_000); + const poll = setInterval(() => { + const hit = frames.find((f) => f['type'] === 'assistant.delta' || f['type'] === 'context.append_loop_event'); + if (hit !== undefined) { + clearInterval(poll); + clearTimeout(timer); + resolve(); + } + }, 25); + }); + ws.close(); + await sleep(200); + }, 60_000); +}); diff --git a/packages/kap-server/test/v2Projection.test.ts b/packages/kap-server/test/v2Projection.test.ts new file mode 100644 index 00000000000..ea8396a1d8d --- /dev/null +++ b/packages/kap-server/test/v2Projection.test.ts @@ -0,0 +1,4054 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { parseServerMessage, type ServerMessage, type SessionInfo, type WorkspaceInfo } from '../src/protocol/v2/messages/index'; +import type { + InteractionPendingRecord, + InteractionResolvedRecord, + ProjectionEvent, +} from '../src/services/v2Projection/agentProjector'; +import { registerHistoryRoutes, historyResponseSchema } from '../src/routes/history'; +import { liveSessionSourceFor, SessionV2Binder, type SessionV2Binding, type V2SessionSource } from '../src/services/v2Projection/binder'; +import { GlobalV2Fanout, type GlobalV2Event } from '../src/services/v2Projection/globalFanout'; +import { ConnectionRegistry } from '../src/transport/ws/connectionRegistry'; +import { WsConnectionV2 } from '../src/transport/ws/v2/wsConnectionV2'; +import { AgentV2Projector } from '../src/services/v2Projection/agentProjector'; +import { buildColdHistory } from '../src/services/v2Projection/coldHistory'; +import { SessionV2Projector } from '../src/services/v2Projection/sessionProjector'; +import type { SessionFactsPatch } from '../src/services/v2Projection/sessionStateComposer'; + +interface FixtureTab { + id: string; + sections: { label: string; items: { note?: string; json: string }[] }[]; +} + +const FIXTURES = JSON.parse( + readFileSync(new URL('./fixtures/v2-examples.json', import.meta.url), 'utf8'), +) as { tabs: FixtureTab[] }; + +interface ScriptStep { + event?: ProjectionEvent; + facts?: SessionFactsPatch & { time: number }; + interaction?: ({ phase: 'pending' } & InteractionPendingRecord) | ({ phase: 'resolved' } & InteractionResolvedRecord); +} + +function fixtureStream(tabId: string, sectionLabel: string): unknown[] { + const tab = FIXTURES.tabs.find((t) => t.id === tabId); + if (!tab) throw new Error(`tab ${tabId} not found`); + const section = tab.sections.find((s) => s.label === sectionLabel); + if (!section) throw new Error(`section ${sectionLabel} not found in ${tabId}`); + return section.items.map((it) => JSON.parse(it.json)); +} + +function runScript(sessionId: string, steps: ScriptStep[]): ServerMessage[] { + const projector = new SessionV2Projector(sessionId); + const out: ServerMessage[] = []; + for (const step of steps) { + if (step.event) out.push(...projector.applyAgentEvent(step.event.agentId ?? 'main', step.event)); + if (step.facts) { + const { time, ...patch } = step.facts; + out.push(...projector.applyFacts(patch, time)); + } + if (step.interaction) { + const i = step.interaction; + if (i.phase === 'pending') { + out.push(...projector.applyInteractionPending('main', { id: i.id, kind: i.kind, toolCallId: i.toolCallId, request: i.request, time: i.time })); + } else { + out.push(...projector.applyInteractionResolved('main', { id: i.id, state: i.state, response: i.response, time: i.time })); + } + } + } + return out; +} + +function expectStream(tabId: string, sectionLabel: string, steps: ScriptStep[]): void { + expectStreams(tabId, [sectionLabel], steps); +} + +function expectStreams(tabId: string, sectionLabels: string[], steps: ScriptStep[]): void { + const expected = sectionLabels + .flatMap((label) => fixtureStream(tabId, label)) + .filter((m) => (m as { type?: string }).type !== 'subscribe' && (m as { type?: string }).type !== 'ack') as { + session_id?: string; + }[]; + const sessionId = expected.find((m) => typeof m.session_id === 'string')?.session_id ?? 's_01'; + const actual = runScript(sessionId, steps); + for (const msg of actual) parseServerMessage(msg); + expect(actual).toEqual(expected); +} + +function expectScenarios(tabId: string, scenarios: { sectionLabel: string; steps: ScriptStep[] }[]): void { + for (const { sectionLabel, steps } of scenarios) expectStream(tabId, sectionLabel, steps); +} + +const T = Date.parse('2026-09-03T10:00:00.000Z'); + +describe('v2Projection × 实例对拍', () => { + it('basic 直播流', () => { + expectStream('basic', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '你好' }], + createdAt: '2026-09-03T10:00:00.000Z', + time: T + 10, + }, + }, + { + event: { + type: 'turn.started', + turnId: 0, + promptId: 'p_01', + origin: { kind: 'user' }, + time: T + 15, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: T + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: T + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: T + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '用户在打招呼,', time: T + 420 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '简短回应即可。', time: T + 580 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '你好!', time: T + 1050 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '有什么可以帮你的?', time: T + 1200 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 1820, output: 24, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: T + 1400, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 1483, time: T + 1500 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 1868, + usage: { total: { inputOther: 1820, output: 24, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + time: T + 1520, + }, + }, + ]); + }); + + it('tool 直播流', () => { + const B = Date.parse('2026-09-03T11:00:00.000Z'); + expectStream('tool', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '执行一下 ls' }], + createdAt: '2026-09-03T11:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 2000, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '用户想看当前目录内容,用 Bash 执行 ls。', time: B + 420 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '好的,执行 `ls`:', time: B + 800 } }, + { event: { type: 'tool.call.delta', turnId: 0, toolCallId: 'call_01', name: 'Bash', argumentsPart: '{"command": "ls', time: B + 1000 } }, + { event: { type: 'tool.call.delta', turnId: 0, toolCallId: 'call_01', argumentsPart: '"}', time: B + 1100 } }, + { event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'Bash', args: { command: 'ls' }, time: B + 1150 } }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { stdout: 'apps\ndocs\npackages\npnpm-workspace.yaml\n', exit_code: 0 }, + time: B + 1600, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2100, output: 96, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 1700, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 1900 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '当前目录下有 4 个条目:', time: B + 2250 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '`apps`、`docs`、`packages` 和 `pnpm-workspace.yaml`。', time: B + 2400 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2240, output: 58, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 2600, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 2683, time: B + 2700 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4494, + usage: { total: { inputOther: 4340, output: 154, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + time: B + 2720, + }, + }, + ]); + }); + + it('multi-tool 直播流', () => { + const B = Date.parse('2026-09-03T12:00:00.000Z'); + expectStream('multi-tool', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '写一个 hello.py 打印当前时间,加个 shebang,然后跑一下' }], + createdAt: '2026-09-03T12:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 2000, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 18, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '先 Write 创建脚本,再 Edit 加 shebang,最后 Bash 运行。', time: B + 420 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来创建 `hello.py`:', time: B + 750 } }, + { + event: { + type: 'tool.call.delta', + turnId: 0, + toolCallId: 'call_01', + name: 'Write', + argumentsPart: '{"path": "hello.py", "content": "from datetime import datetime\\nprint(datetime.now())\\n"}', + time: B + 1000, + }, + }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Write', + args: { path: 'hello.py', content: 'from datetime import datetime\nprint(datetime.now())\n' }, + time: B + 1100, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { bytes_written: 52 }, time: B + 1400 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2400, output: 130, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 1500, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 1700 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '补上 shebang:', time: B + 1950 } }, + { + event: { + type: 'tool.call.delta', + turnId: 0, + toolCallId: 'call_02', + name: 'Edit', + argumentsPart: '{"path": "hello.py", "old": "from datetime import datetime", "new": "#!/usr/bin/env python3\\nfrom datetime import datetime"}', + time: B + 2100, + }, + }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_02', + name: 'Edit', + args: { path: 'hello.py', old: 'from datetime import datetime', new: '#!/usr/bin/env python3\nfrom datetime import datetime' }, + time: B + 2200, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_02', output: { applied: true }, time: B + 2400 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2580, output: 74, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 2500, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 3, time: B + 2700 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '跑一下验证:', time: B + 2850 } }, + { + event: { + type: 'tool.call.delta', + turnId: 0, + toolCallId: 'call_03', + name: 'Bash', + argumentsPart: '{"command": "python3 hello.py"}', + time: B + 3000, + }, + }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_03', name: 'Bash', args: { command: 'python3 hello.py' }, time: B + 3100 }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_03', + output: { stdout: '2026-09-03 12:00:03.587201\n', exit_code: 0 }, + time: B + 3600, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 3, + usage: { inputOther: 2720, output: 66, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 3700, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 4, time: B + 3900 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '完成。`hello.py` 已创建并加上 shebang,', time: B + 4100 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '运行输出当前时间,一切正常。', time: B + 4150 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 4, + usage: { inputOther: 2830, output: 62, inputCacheRead: 0, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4300, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4383, time: B + 4400 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 10862, + usage: { total: { inputOther: 10530, output: 332, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + time: B + 4420, + }, + }, + ]); + }); + + it('todo 直播流', () => { + const B = Date.parse('2026-09-03T12:00:00.000Z'); + const todos1 = [ + { title: '查看登录页代码,定位白屏原因', status: 'in_progress' }, + { title: '修复崩溃', status: 'pending' }, + { title: '跑测试验证', status: 'pending' }, + ]; + const todos2 = [ + { title: '查看登录页代码,定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'in_progress' }, + { title: '跑测试验证', status: 'pending' }, + ]; + const todos3 = [ + { title: '查看登录页代码,定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'done' }, + { title: '跑测试验证', status: 'in_progress' }, + ]; + const todos4 = [ + { title: '查看登录页代码,定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'done' }, + { title: '跑测试验证', status: 'done' }, + ]; + expectStream('todo', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '登录页点登录直接白屏,修一下' }], + createdAt: '2026-09-03T12:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '白屏一般是运行时错误。分三步:', time: B + 450 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '定位、修复、验证。', time: B + 580 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我分三步处理:', time: B + 1050 } }, + { event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'TodoWrite', args: { items: todos1 }, time: B + 1300 } }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { updated: true }, time: B + 1350 } }, + { event: { type: 'tools.update_store', key: 'todo', value: todos1, time: B + 1360 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_02', + name: 'Read', + args: { path: 'apps/web/src/views/LoginView.vue' }, + time: B + 1500, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_02', + output: { content: '', lines: 214 }, + time: B + 2200, + }, + }, + { event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_03', name: 'TodoWrite', args: { items: todos2 }, time: B + 2300 } }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_03', output: { updated: true }, time: B + 2350 } }, + { event: { type: 'tools.update_store', key: 'todo', value: todos2, time: B + 2360 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_04', + name: 'Edit', + args: { path: 'apps/web/src/views/LoginView.vue', old: 'const token = user.token;', new: 'const token = user?.token;' }, + time: B + 2500, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_04', output: { applied: true }, time: B + 3000 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2900, output: 78, inputCacheRead: 10000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 3100, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 3150 } }, + { event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_05', name: 'TodoWrite', args: { items: todos3 }, time: B + 3300 } }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_05', output: { updated: true }, time: B + 3350 } }, + { event: { type: 'tools.update_store', key: 'todo', value: todos3, time: B + 3360 } }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_06', name: 'Bash', args: { command: 'pnpm test -- login' }, time: B + 3500 }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_06', + output: { stdout: 'Test Files 1 passed (1)\n Tests 6 passed (6)\n', exit_code: 0 }, + time: B + 5500, + }, + }, + { event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_07', name: 'TodoWrite', args: { items: todos4 }, time: B + 5600 } }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_07', output: { updated: true }, time: B + 5650 } }, + { event: { type: 'tools.update_store', key: 'todo', value: todos4, time: B + 5660 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '修好了:`handleLogin` 在 `user` 为空时直接读 `token` 导致白屏,', time: B + 6000 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '已加可选链判空;登录相关 6 个测试全部通过。', time: B + 6300 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2600, output: 84, inputCacheRead: 9500, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 6600, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 6683, time: B + 6700 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5700, + usage: { total: { inputOther: 5500, output: 162, inputCacheRead: 19500, inputCacheCreation: 0 } }, + }, + time: B + 6720, + }, + }, + ]); + }); + + it('queue-abort 直播流', () => { + const B = Date.parse('2026-09-03T11:00:00.000Z'); + expectStreams('queue-abort', ['排队与接棒(WS)', '中断(WS)'], [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '跑一下测试套件' }], + createdAt: '2026-09-03T11:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '测试命令免审批,直接跑。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来跑测试:', time: B + 1050 } }, + { + event: { + type: 'tool.call.delta', + turnId: 0, + toolCallId: 'call_01', + name: 'Bash', + argumentsPart: '{"command": "pnpm test"}', + time: B + 1300, + }, + }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'Bash', args: { command: 'pnpm test' }, time: B + 1500 }, + }, + { + event: { + type: 'tool.progress', + turnId: 0, + toolCallId: 'call_01', + update: { kind: 'stdout', text: '… 12 passed …' }, + time: B + 3000, + }, + }, + { + event: { + type: 'prompt.submitted', + promptId: 'p_02', + status: 'queued', + content: [{ type: 'text', text: '把失败的用例列出来' }], + createdAt: '2026-09-03T11:00:04.000Z', + time: B + 4010, + }, + }, + { + event: { + type: 'tool.progress', + turnId: 0, + toolCallId: 'call_01', + update: { kind: 'stdout', text: '… 2 failed …' }, + time: B + 5500, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { stdout: 'Test Files 1 failed | 3 passed (4)\n Tests 2 failed | 18 passed (20)\n', exit_code: 1 }, + time: B + 6000, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2600, output: 60, inputCacheRead: 9000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 6100, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 6150 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '测试跑完了:18 通过、2 失败。', time: B + 6500 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2800, output: 40, inputCacheRead: 11000, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 6800, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 6883, time: B + 6900 } }, + { event: { type: 'prompt.started', promptId: 'p_02', time: B + 6920 } }, + { event: { type: 'turn.started', turnId: 1, promptId: 'p_02', origin: { kind: 'user' }, time: B + 6930 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 1, step: 0, phase: 'running', since: B + 6930 } }, + status: { + contextTokens: 4400, + usage: { total: { inputOther: 5400, output: 100, inputCacheRead: 20000, inputCacheCreation: 0 } }, + }, + time: B + 6940, + }, + }, + { event: { type: 'turn.step.started', turnId: 1, step: 1, time: B + 6950 } }, + { event: { type: 'thinking.delta', turnId: 1, delta: '从刚才的输出里挑失败用例即可,', time: B + 7250 } }, + { event: { type: 'thinking.delta', turnId: 1, delta: '不用重跑。', time: B + 7400 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '失败的两个用例都在 `auth` 目录下:', time: B + 7900 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '`login.spec.ts` 的「过期 token 应跳转登录页」、', time: B + 8100 } }, + { + event: { + type: 'turn.step.interrupted', + turnId: 1, + step: 1, + reason: 'aborted by user', + time: B + 8610, + }, + }, + { event: { type: 'turn.ended', turnId: 1, reason: 'cancelled', time: B + 8620 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 6500, + usage: { total: { inputOther: 7600, output: 135, inputCacheRead: 26000, inputCacheCreation: 0 } }, + }, + time: B + 8640, + }, + }, + { event: { type: 'prompt.aborted', promptId: 'p_02', abortedAt: '2026-09-03T11:00:08.650Z', time: B + 8650 } }, + ]); + }); + + it('tool-error 直播流', () => { + const B = Date.parse('2026-09-03T15:00:00.000Z'); + expectStream('tool-error', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '把配置里的超时时间改成 30 秒' }], + createdAt: '2026-09-03T15:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '超时配置在 config/server.toml,直接 Edit。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来改超时配置:', time: B + 1050 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Edit', + args: { path: 'config/server.toml', old: 'timeout = 10', new: 'timeout = 30' }, + time: B + 1500, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: 'old_string 在文件中未找到', + isError: true, + time: B + 1900, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2300, output: 46, inputCacheRead: 7200, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 2000, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 2050 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '没匹配上,我先看下文件实际内容:', time: B + 2400 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_02', + name: 'Read', + args: { path: 'config/server.toml' }, + time: B + 2800, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_02', + output: { content: '[server]\nrequest_timeout = 10\n…', lines: 18 }, + time: B + 3200, + }, + }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_03', + name: 'Edit', + args: { path: 'config/server.toml', old: 'request_timeout = 10', new: 'request_timeout = 30' }, + time: B + 3400, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_03', output: { applied: true }, time: B + 3800 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '改好了:超时配置项是 `request_timeout`(不是 `timeout`),已从 10 改为 30。', + time: B + 4200, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2800, output: 82, inputCacheRead: 10400, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4500, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4583, time: B + 4600 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5000, + usage: { total: { inputOther: 5100, output: 128, inputCacheRead: 17600, inputCacheCreation: 0 } }, + }, + time: B + 4620, + }, + }, + ]); + }); + + it('llm-retry 直播流', () => { + const B = Date.parse('2026-09-03T15:30:00.000Z'); + expectStream('llm-retry', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '总结一下这个项目的目录结构' }], + createdAt: '2026-09-03T15:30:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '', time: B + 300 } }, + { + event: { + type: 'turn.step.retrying', + turnId: 0, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 2000, + errorName: 'RateLimitError', + errorMessage: '429 too many requests', + statusCode: 429, + time: B + 500, + }, + }, + { event: { type: 'thinking.delta', turnId: 0, delta: '先列顶层目录,', time: B + 2950 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '再按功能分组说明。', time: B + 3100 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '项目分四块:`apps/` 三个端(desktop、web、auth-login)、', + time: B + 3700, + }, + }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '`packages/` 八个共享包、`scripts/` 构建发布脚本、`docs/` 设计文档。', + time: B + 3900, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2400, output: 62, inputCacheRead: 8600, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4200, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4283, time: B + 4300 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4300, + usage: { total: { inputOther: 2400, output: 62, inputCacheRead: 8600, inputCacheCreation: 0 } }, + }, + time: B + 4320, + }, + }, + ]); + }); + + it('approval 起跑与批准', () => { + const B = Date.parse('2026-09-03T10:00:00.000Z'); + expectStreams('approval', ['起跑与审批请求(WS)', 'A · 批准(WS)'], [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '把登录页崩溃的复现脚本跑一下' }], + createdAt: '2026-09-03T10:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '复现脚本在 `scripts/` 下,', time: B + 450 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: 'node 执行需要审批。', time: B + 580 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来跑一下复现脚本:', time: B + 1050 } }, + { + event: { + type: 'tool.call.delta', + turnId: 0, + toolCallId: 'call_01', + name: 'Bash', + argumentsPart: '{"command": "node scripts/repro-login-crash.mjs', + time: B + 1300, + }, + }, + { event: { type: 'tool.call.delta', turnId: 0, toolCallId: 'call_01', argumentsPart: '"}', time: B + 1400 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Bash', + args: { command: 'node scripts/repro-login-crash.mjs' }, + time: B + 1500, + }, + }, + { + event: { + type: 'permission.approval.requested', + id: 'ap_01', + turnId: 0, + toolCallId: 'call_01', + toolName: 'Bash', + action: '运行脚本需要执行权限', + toolInput: { command: 'node scripts/repro-login-crash.mjs' }, + time: B + 1510, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'approval' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 1510 } }, + status: { + contextTokens: 4280, + usage: { + currentTurn: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + total: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + }, + }, + time: B + 1520, + }, + }, + { + event: { + type: 'permission.approval.resolved', + id: 'ap_01', + turnId: 0, + toolCallId: 'call_01', + toolName: 'Bash', + action: '运行脚本需要执行权限', + toolInput: { command: 'node scripts/repro-login-crash.mjs' }, + decision: 'approved', + time: B + 5100, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4280, + usage: { + currentTurn: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + total: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + }, + }, + time: B + 5110, + }, + }, + { + event: { + type: 'tool.progress', + turnId: 0, + toolCallId: 'call_01', + update: { kind: 'stdout', text: "TypeError: Cannot read properties of undefined (reading 'token')" }, + time: B + 5900, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { stdout: "TypeError: Cannot read properties of undefined (reading 'token')\n at handleLogin (LoginView.vue:87)\n", exit_code: 1 }, + time: B + 6200, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 6300, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 6350 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '复现成功,报错和浏览器里看到的一致:', time: B + 6700 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '`handleLogin` 读取了 undefined 的 `token` 字段(`LoginView.vue:87`)。', time: B + 6900 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 3100, output: 88, inputCacheRead: 12800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 7200, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 7283, time: B + 7300 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5700, + usage: { total: { inputOther: 5500, output: 140, inputCacheRead: 20800, inputCacheCreation: 0 } }, + }, + time: B + 7320, + }, + }, + ]); + }); + + it('approval 起跑与拒绝', () => { + const B = Date.parse('2026-09-03T10:00:00.000Z'); + expectStreams('approval', ['起跑与审批请求(WS)', 'B · 拒绝(WS)'], [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '把登录页崩溃的复现脚本跑一下' }], + createdAt: '2026-09-03T10:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '复现脚本在 `scripts/` 下,', time: B + 450 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: 'node 执行需要审批。', time: B + 580 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来跑一下复现脚本:', time: B + 1050 } }, + { + event: { + type: 'tool.call.delta', + turnId: 0, + toolCallId: 'call_01', + name: 'Bash', + argumentsPart: '{"command": "node scripts/repro-login-crash.mjs', + time: B + 1300, + }, + }, + { event: { type: 'tool.call.delta', turnId: 0, toolCallId: 'call_01', argumentsPart: '"}', time: B + 1400 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Bash', + args: { command: 'node scripts/repro-login-crash.mjs' }, + time: B + 1500, + }, + }, + { + event: { + type: 'permission.approval.requested', + id: 'ap_01', + turnId: 0, + toolCallId: 'call_01', + toolName: 'Bash', + action: '运行脚本需要执行权限', + toolInput: { command: 'node scripts/repro-login-crash.mjs' }, + time: B + 1510, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'approval' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 1510 } }, + status: { + contextTokens: 4280, + usage: { + currentTurn: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + total: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + }, + }, + time: B + 1520, + }, + }, + { + event: { + type: 'permission.approval.resolved', + id: 'ap_01', + turnId: 0, + toolCallId: 'call_01', + toolName: 'Bash', + action: '运行脚本需要执行权限', + toolInput: { command: 'node scripts/repro-login-crash.mjs' }, + decision: 'rejected', + time: B + 5100, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4280, + usage: { + currentTurn: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + total: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + }, + }, + time: B + 5110, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: 'rejected by user', + isError: true, + time: B + 5200, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2400, output: 52, inputCacheRead: 8000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 5300, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 5350 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '好,那不跑了。', time: B + 5700 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '需要我换个方式排查吗——比如直接读 `handleLogin` 的实现?', time: B + 5900 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2900, output: 76, inputCacheRead: 12100, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 6200, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 6283, time: B + 6300 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5500, + usage: { total: { inputOther: 5300, output: 128, inputCacheRead: 20100, inputCacheCreation: 0 } }, + }, + time: B + 6320, + }, + }, + ]); + }); + + it('question 直播流', () => { + const B = Date.parse('2026-09-03T14:00:00.000Z'); + expectStreams('question', ['起跑与提问(WS)', '回答与继续(WS)'], [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '把 README 的安装命令更新成 pnpm' }], + createdAt: '2026-09-03T14:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '安装命令在快速开始和开发者文档各有一处。', time: B + 450 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '范围不明,先问用户。', time: B + 580 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: 'README 里有两处安装命令,先确认范围:', time: B + 1050 } }, + { + interaction: { + phase: 'pending', + id: 'q_01', + kind: 'question', + request: { + questions: [ + { id: 'q1', question: 'README 里有两处安装命令(快速开始、开发者文档),要都更新吗?', options: ['两处都改', '只改快速开始'] }, + ], + }, + time: B + 1500, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'question' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 1500 } }, + status: { + contextTokens: 4150, + usage: { + currentTurn: { inputOther: 2300, output: 48, inputCacheRead: 7600, inputCacheCreation: 0 }, + total: { inputOther: 2300, output: 48, inputCacheRead: 7600, inputCacheCreation: 0 }, + }, + }, + time: B + 1520, + }, + }, + { + interaction: { + phase: 'resolved', + id: 'q_01', + state: 'answered', + response: { answers: { q1: '两处都改' } }, + time: B + 5100, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4150, + usage: { + currentTurn: { inputOther: 2300, output: 48, inputCacheRead: 7600, inputCacheCreation: 0 }, + total: { inputOther: 2300, output: 48, inputCacheRead: 7600, inputCacheCreation: 0 }, + }, + }, + time: B + 5110, + }, + }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Edit', + args: { path: 'README.md', old: 'npm install && npm run dev', new: 'pnpm install && pnpm dev' }, + time: B + 5300, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { applied: true }, time: B + 5800 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2300, output: 48, inputCacheRead: 7600, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 5900, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 5950 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_02', + name: 'Edit', + args: { path: 'README.md', old: 'npm run build && npm start', new: 'pnpm build && pnpm start' }, + time: B + 6100, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_02', output: { applied: true }, time: B + 6500 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '两处都改好了:快速开始和开发者文档的安装命令已更新为 `pnpm` 版。', + time: B + 6900, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2500, output: 62, inputCacheRead: 8800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 7200, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 7283, time: B + 7300 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4900, + usage: { total: { inputOther: 4800, output: 110, inputCacheRead: 16400, inputCacheCreation: 0 } }, + }, + time: B + 7320, + }, + }, + ]); + }); + + it('background-task 直播流', () => { + const B = Date.parse('2026-09-03T16:00:00.000Z'); + expectStreams('background-task', ['转后台(WS)', '任务完成与新 turn(WS)'], [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '跑一下完整构建' }], + createdAt: '2026-09-03T16:00:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '完整构建要几分钟,先跑起来,太久就转后台。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来跑完整构建:', time: B + 1050 } }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'Bash', args: { command: 'pnpm build' }, time: B + 1500 }, + }, + { + event: { + type: 'tool.progress', + turnId: 0, + toolCallId: 'call_01', + update: { kind: 'stdout', text: '… compiling packages (3/8) …' }, + time: B + 3000, + }, + }, + { + event: { + type: 'task.started', + info: { + taskId: 'task_01', + kind: 'shell', + status: 'running', + description: 'pnpm build', + detached: true, + outputTail: '… compiling packages (3/8) …', + }, + time: B + 4000, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { detached: true, task_id: 'task_01' }, + time: B + 4010, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2500, output: 58, inputCacheRead: 9200, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 4100, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 4150 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '构建量比较大,已转后台跑(task_01),完成后我告诉你。', time: B + 4500 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2700, output: 66, inputCacheRead: 10100, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4800, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4883, time: B + 4900 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5300, + usage: { total: { inputOther: 5200, output: 124, inputCacheRead: 19300, inputCacheCreation: 0 } }, + }, + time: B + 4920, + }, + }, + { + event: { + type: 'shell.output', + taskId: 'task_01', + update: { kind: 'stdout', text: '… packages (8/8) done, writing dist …' }, + time: B + 450000, + }, + }, + { + event: { + type: 'task.terminated', + info: { taskId: 'task_01', kind: 'shell', status: 'completed', description: 'pnpm build', resultSummary: '构建成功:8 个包全部编译通过' }, + outputTail: '… build finished successfully in 7m 56s …', + time: B + 478500, + }, + }, + { + event: { + type: 'task.notified', + title: '后台构建完成', + body: '构建成功:8 个包全部编译通过', + severity: 'info', + notificationType: 'task.completed', + sourceKind: 'background_task', + sourceId: 'task_01', + time: B + 478510, + }, + }, + { event: { type: 'turn.started', turnId: 1, origin: { kind: 'task', taskId: 'task_01' }, time: B + 478600 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 1, step: 0, phase: 'running', since: B + 478600 } }, + status: { + contextTokens: 5300, + usage: { total: { inputOther: 5200, output: 124, inputCacheRead: 19300, inputCacheCreation: 0 } }, + }, + time: B + 478610, + }, + }, + { event: { type: 'turn.step.started', turnId: 1, step: 1, time: B + 478620 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '构建完成了:8 个包全部编译通过,产物在各自的 `dist/`。', time: B + 479000 } }, + { + event: { + type: 'turn.step.completed', + turnId: 1, + step: 1, + usage: { inputOther: 2900, output: 44, inputCacheRead: 12000, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 479300, + }, + }, + { event: { type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 797, time: B + 479400 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5600, + usage: { total: { inputOther: 8100, output: 168, inputCacheRead: 31300, inputCacheCreation: 0 } }, + }, + time: B + 479410, + }, + }, + ]); + }); + + it('compaction 直播流', () => { + const B = Date.parse('2026-09-03T16:30:00.000Z'); + expectStream('compaction', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + turnId: 8, + content: [{ type: 'text', text: '接着上面的讨论,把新页面的路由也加上' }], + createdAt: '2026-09-03T16:30:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 8, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 8, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 241000, + maxContextTokens: 262144, + usage: { total: { inputOther: 20337, output: 4398, inputCacheRead: 128912, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { + event: { + type: 'compaction.completed', + result: { summary: '前 8 轮讨论摘要', compactedCount: 42, tokensBefore: 241000, tokensAfter: 62000 }, + time: B + 100, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 8, step: 0, phase: 'running', since: B + 15 } }, + status: { contextTokens: 62000 }, + time: B + 110, + }, + }, + { event: { type: 'turn.step.started', turnId: 8, step: 1, time: B + 120 } }, + { event: { type: 'thinking.delta', turnId: 8, delta: '路由集中在 router 配置文件,加一条即可。', time: B + 550 } }, + { event: { type: 'assistant.delta', turnId: 8, delta: '我来加路由:', time: B + 1150 } }, + { + event: { + type: 'tool.call.started', + turnId: 8, + toolCallId: 'call_01', + name: 'Edit', + args: { + path: 'apps/web/src/router.ts', + old: " { path: '/login', component: LoginView },", + new: " { path: '/login', component: LoginView },\n { path: '/new-page', component: NewPageView },", + }, + time: B + 1500, + }, + }, + { event: { type: 'tool.result', turnId: 8, toolCallId: 'call_01', output: { applied: true }, time: B + 1900 } }, + { event: { type: 'assistant.delta', turnId: 8, delta: '加好了:`/new-page` 路由已注册到 `router.ts`。', time: B + 2300 } }, + { + event: { + type: 'turn.step.completed', + turnId: 8, + step: 1, + usage: { inputOther: 3100, output: 72, inputCacheRead: 9600, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 2600, + }, + }, + { event: { type: 'turn.ended', turnId: 8, reason: 'completed', durationMs: 2683, time: B + 2700 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 65200, + usage: { total: { inputOther: 20600, output: 4460, inputCacheRead: 129500, inputCacheCreation: 0 } }, + }, + time: B + 2720, + }, + }, + ]); + }); + + it('undo 直播流', () => { + const B = Date.parse('2026-09-03T18:10:00.000Z'); + expectStream('undo', 'undo(WS)', [ + { event: { type: 'context.undone', turns: 1, fromTurnId: 0, time: B + 5000 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 5000, + maxContextTokens: 262144, + usage: { total: { inputOther: 5100, output: 128, inputCacheRead: 17600, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 5010, + }, + }, + ]); + }); + + it('big-output 直播流', () => { + const B = Date.parse('2026-09-03T18:30:00.000Z'); + expectStream('big-output', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '跑一下全量测试' }], + createdAt: '2026-09-03T18:30:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '全量测试输出会很大,截断内联即可。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '我来跑全量测试:', time: B + 1050 } }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'Bash', args: { command: 'pnpm test --all' }, time: B + 1500 }, + }, + { + event: { + type: 'tool.progress', + turnId: 0, + toolCallId: 'call_01', + update: { kind: 'stdout', text: '… running 43 test files …' }, + time: B + 20000, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { + stdout: '…\n ✓ packages/app-core/src/lib/wire.test.ts (42 tests)\n ✗ packages/app-client/src/stores/chat.test.ts (2 failed)\nTest Files 2 failed | 41 passed (43)\n Tests 2 failed | 386 passed (388)\n', + exit_code: 1, + truncated: true, + total_lines: 4821, + }, + time: B + 45200, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2800, output: 72, inputCacheRead: 11000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 45300, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 45350 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '全量 43 个测试文件:41 通过、2 失败(都在 `app-client` 的 chat store)。', + time: B + 45700, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: '完整日志 4821 行,需要我拉出来定位吗?', time: B + 45800 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 3000, output: 80, inputCacheRead: 12100, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 46000, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 46083, time: B + 46100 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5900, + usage: { total: { inputOther: 5800, output: 152, inputCacheRead: 23100, inputCacheCreation: 0 } }, + }, + time: B + 46120, + }, + }, + ]); + }); + + it('plan 直播流', () => { + const B = Date.parse('2026-09-03T17:50:00.000Z'); + expectStream('plan', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '把登录页改造成支持 SSO' }], + createdAt: '2026-09-03T17:50:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '改造涉及多个文件,先进入 plan 模式出方案。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '这个改造涉及多个文件,我先出方案再动手:', time: B + 1050 } }, + { event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'EnterPlanMode', time: B + 1500 } }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { entered: true }, time: B + 1600 } }, + { event: { type: 'agent.status.updated', planMode: true, time: B + 1610 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4100, + usage: { total: { inputOther: 2200, output: 58, inputCacheRead: 8000, inputCacheCreation: 0 } }, + }, + modes: { plan: { version: 0 } }, + time: B + 1620, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: 'SSO 改造分四步:1. 接入 OAuth 客户端(`auth/oauth.ts', time: B + 2000 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '`);2. 登录页加 SSO 按钮;3. 新增 `/callback` 路由处理回跳;4. 本地会话与 SSO 会话合并。', time: B + 2300 } }, + { + event: { + type: 'plan.revision', + id: 'rev_01', + version: 1, + key: 'plans/sso.md', + sha256: 'deadbeef', + bytes: 512, + summary: 'SSO 改造四步', + time: B + 2700, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4300, + usage: { total: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 } }, + }, + modes: { plan: { version: 1, review_path: 'plans/sso.md' } }, + time: B + 2710, + }, + }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_02', + name: 'ExitPlanMode', + args: { plan_key: 'plans/sso.md' }, + display: { kind: 'plan_review', plan: '## 目标\n\n接入 SSO 登录,改造涉及多个文件。', path: 'plans/sso.md' }, + time: B + 2900, + }, + }, + { + event: { + type: 'permission.approval.requested', + id: 'ap_01', + turnId: 0, + toolCallId: 'call_02', + toolName: 'ExitPlanMode', + action: '请审查并批准实施方案', + toolInput: { plan_key: 'plans/sso.md' }, + display: { kind: 'plan_review', plan: '## 目标\n\n接入 SSO 登录,改造涉及多个文件。', path: 'plans/sso.md' }, + time: B + 2910, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'approval' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 2910 } }, + status: { + contextTokens: 4300, + usage: { + currentTurn: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 }, + total: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 }, + }, + }, + time: B + 2920, + }, + }, + { + event: { + type: 'permission.approval.resolved', + id: 'ap_01', + turnId: 0, + toolCallId: 'call_02', + toolName: 'ExitPlanMode', + action: '请审查并批准实施方案', + toolInput: { plan_key: 'plans/sso.md' }, + decision: 'approved', + time: B + 5000, + }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4300, + usage: { + currentTurn: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 }, + total: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 }, + }, + }, + time: B + 5010, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_02', output: { approved: true }, time: B + 5020 } }, + { event: { type: 'agent.status.updated', planMode: false, time: B + 5030 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 4300, + usage: { total: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 } }, + }, + time: B + 5040, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: '开始实施,先接 OAuth 客户端:', time: B + 5400 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_03', + name: 'Write', + args: { path: 'apps/web/src/auth/oauth.ts', content: '…(OAuth 客户端封装)…' }, + time: B + 5800, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_03', output: { bytes_written: 612 }, time: B + 6300 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2400, output: 96, inputCacheRead: 8600, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 6400, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 6450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '第 1 步完成。继续第 2 步(登录页 SSO 按钮)?', time: B + 6800 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2900, output: 74, inputCacheRead: 11200, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 7100, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 7183, time: B + 7200 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5600, + usage: { total: { inputOther: 5300, output: 170, inputCacheRead: 19800, inputCacheCreation: 0 } }, + }, + time: B + 7220, + }, + }, + ]); + }); + + it('goal 直播流', () => { + const B = Date.parse('2026-09-03T18:00:00.000Z'); + expectStream('goal', '流式(WS)', [ + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + goal: { + objective: '修复登录页白屏并让登录测试全绿', + status: 'active', + completionCriterion: 'pnpm test -- login 全部通过', + budgetUsed: 0, + budgetLimit: 50, + }, + time: B, + }, + }, + { + event: { + type: 'turn.started', + turnId: 0, + promptId: 'p_01', + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + time: B + 15, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '先复现定位,再修,最后跑测试验证达标条件。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '先修复崩溃点:', time: B + 1050 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Edit', + args: { path: 'apps/web/src/views/LoginView.vue', old: 'const token = user.token;', new: 'const token = user?.token;' }, + time: B + 1500, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { applied: true }, time: B + 2000 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 2100, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 2150 } }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_02', name: 'Bash', args: { command: 'pnpm test -- login' }, time: B + 2300 }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_02', + output: { stdout: 'Test Files 1 passed (1)\n Tests 6 passed (6)\n', exit_code: 0 }, + time: B + 4300, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: '目标达成:白屏已修复,登录相关 6 个测试全部通过。', time: B + 4700 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 1, phase: 'running', since: B + 15 } }, + status: { + contextTokens: 5400, + usage: { total: { inputOther: 5300, output: 140, inputCacheRead: 18600, inputCacheCreation: 0 } }, + }, + goal: { + objective: '修复登录页白屏并让登录测试全绿', + status: 'complete', + completionCriterion: 'pnpm test -- login 全部通过', + budgetUsed: 3, + budgetLimit: 50, + }, + time: B + 4950, + }, + }, + { + event: { + type: 'goal.updated', + snapshot: { + objective: '修复登录页白屏并让登录测试全绿', + status: 'complete', + completionCriterion: 'pnpm test -- login 全部通过', + budgetUsed: 3, + budgetLimit: 50, + }, + time: B + 4960, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2700, output: 76, inputCacheRead: 10200, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 5000, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 5083, time: B + 5100 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5400, + usage: { total: { inputOther: 5300, output: 140, inputCacheRead: 18600, inputCacheCreation: 0 } }, + }, + goal: null, + time: B + 5110, + }, + }, + ]); + }); + + it('sidechat 直播流', () => { + const B = Date.parse('2026-09-03T17:40:00.000Z'); + expectStream('sidechat', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + agentId: 'side_01', + promptId: 'p_01', + status: 'running', + turnId: 1, + content: [{ type: 'text', text: '`user?.token` 是啥语法?' }], + createdAt: '2026-09-03T17:40:00.000Z', + time: B + 10, + }, + }, + { + event: { type: 'turn.started', agentId: 'side_01', turnId: 1, promptId: 'p_01', origin: { kind: 'side' }, time: B + 15 }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 1, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 5700, + maxContextTokens: 262144, + usage: { total: { inputOther: 5500, output: 162, inputCacheRead: 19500, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', agentId: 'side_01', turnId: 1, step: 1, time: B + 22 } }, + { + event: { + type: 'assistant.delta', + agentId: 'side_01', + turnId: 1, + delta: '`user?.token` 是可选链:`user` 为 null / undefined 时整个表达式短路为 undefined,', + time: B + 500, + }, + }, + { + event: { type: 'assistant.delta', agentId: 'side_01', turnId: 1, delta: '不会再抛 TypeError。昨天的白屏正是缺了它。', time: B + 700 }, + }, + { + event: { + type: 'turn.step.completed', + agentId: 'side_01', + turnId: 1, + step: 1, + usage: { inputOther: 2100, output: 48, inputCacheRead: 6000, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 1000, + }, + }, + { event: { type: 'turn.ended', agentId: 'side_01', turnId: 1, reason: 'completed', durationMs: 1083, time: B + 1100 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 5900, + usage: { total: { inputOther: 7600, output: 210, inputCacheRead: 25500, inputCacheCreation: 0 } }, + }, + time: B + 1120, + }, + }, + ]); + }); + + it('attachment 直播流', () => { + const B = Date.parse('2026-09-03T18:20:00.000Z'); + expectStream('attachment', '流式(WS)', [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '看下这个报错截图' }], + attachmentIds: ['att_01'], + createdAt: '2026-09-03T18:20:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '截图里是 TypeError,位置在 handleLogin:87。', time: B + 450 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '截图显示 `TypeError: Cannot read properties of undefined (reading \'token\')`,', + time: B + 1050, + }, + }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '发生在 `LoginView.vue:87` 的 `handleLogin`——`user` 为空时直接读了 `token`。', + time: B + 1250, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2600, output: 66, inputCacheRead: 9000, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 1600, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 1683, time: B + 1700 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4500, + usage: { total: { inputOther: 2600, output: 66, inputCacheRead: 9000, inputCacheCreation: 0 } }, + }, + time: B + 1720, + }, + }, + ]); + }); + + it('cron 直播流', () => { + const B = Date.parse('2026-09-03T18:40:00.000Z'); + expectStream('cron', '流式(WS)', [ + { + event: { + type: 'cron.fired', + promptId: 'p_01', + origin: { kind: 'cron_job', jobId: 'cron_01', cron: '0 9 * * 1-5', recurring: true, coalescedCount: 0, stale: false }, + prompt: '跑一遍登录相关测试并汇报结果', + time: B, + }, + }, + { + event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'cron_job', jobId: 'cron_01' }, time: B + 15 }, + }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '定时任务:跑登录测试并汇报。', time: B + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '开始执行定时任务:', time: B + 1050 } }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_01', name: 'Bash', args: { command: 'pnpm test -- login' }, time: B + 1500 }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { stdout: 'Test Files 1 passed (1)\n Tests 6 passed (6)\n', exit_code: 0 }, + time: B + 3500, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2400, output: 58, inputCacheRead: 9000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 3600, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 3650 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '定时报告:登录相关 6 个测试全部通过,无异常。', time: B + 4000 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 2600, output: 62, inputCacheRead: 9800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4300, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4383, time: B + 4400 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4800, + usage: { total: { inputOther: 5000, output: 120, inputCacheRead: 18800, inputCacheCreation: 0 } }, + }, + time: B + 4410, + }, + }, + ]); + }); + + it('injection 三场景', () => { + const S = Date.parse('2026-09-03T18:50:00.000Z'); + const C = Date.parse('2026-09-04T08:59:50.000Z'); + const K = Date.parse('2026-09-04T10:30:00.000Z'); + expectScenarios('injection', [ + { + sectionLabel: 'steer(WS)', + steps: [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '把登录页白屏修一下' }], + createdAt: '2026-09-03T18:50:00.000Z', + time: S + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: S + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: S + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: S + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: S + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: '白屏是 user 为空读 token 导致,加可选链。', time: S + 450 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: '先修复崩溃点:', time: S + 1050 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Edit', + args: { path: 'apps/web/src/views/LoginView.vue', old: 'const token = user.token;', new: 'const token = user?.token;' }, + time: S + 1500, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { applied: true }, time: S + 2000 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: S + 2100, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: S + 2150 } }, + { + event: { type: 'tool.call.started', turnId: 0, toolCallId: 'call_02', name: 'Bash', args: { command: 'pnpm test -- login' }, time: S + 2300 }, + }, + { + event: { + type: 'prompt.submitted', + promptId: 'p_02', + status: 'queued', + steer: true, + content: [{ type: 'text', text: '顺便把超时时间也改成 30' }], + createdAt: '2026-09-03T18:50:03.000Z', + time: S + 3000, + }, + }, + { + event: { + type: 'prompt.steered', + activePromptId: 'p_01', + promptIds: ['p_02'], + content: [{ type: 'text', text: '顺便把超时时间也改成 30' }], + steeredAt: '2026-09-03T18:50:03.000Z', + time: S + 3010, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_02', + output: { stdout: 'Test Files 1 passed (1)\n Tests 6 passed (6)\n', exit_code: 0 }, + time: S + 4500, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: '测试通过,白屏修好了。接着把超时改成 30:', time: S + 4900 } }, + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_03', + name: 'Edit', + args: { path: 'config/server.toml', old: 'request_timeout = 10', new: 'request_timeout = 30' }, + time: S + 5300, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_03', output: { applied: true }, time: S + 5700 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '都完成了:白屏已修复(测试全绿),超时已改为 30 秒。', + time: S + 6100, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 3200, output: 118, inputCacheRead: 13600, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: S + 6400, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 6483, time: S + 6500 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 6100, + usage: { total: { inputOther: 5800, output: 182, inputCacheRead: 23400, inputCacheCreation: 0 } }, + }, + time: S + 6530, + }, + }, + ], + }, + { + sectionLabel: 'cron · 忙时(WS)', + steps: [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + turnId: 1, + content: [{ type: 'text', text: '重构 auth 模块' }], + createdAt: '2026-09-04T08:59:50.000Z', + time: C + 10, + }, + }, + { event: { type: 'turn.started', turnId: 1, promptId: 'p_01', origin: { kind: 'user' }, time: C + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 1, step: 0, phase: 'running', since: C + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 6100, + maxContextTokens: 262144, + usage: { total: { inputOther: 5800, output: 182, inputCacheRead: 23400, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: C + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 1, step: 1, time: C + 22 } }, + { event: { type: 'thinking.delta', turnId: 1, delta: '先拆 session 签发逻辑。', time: C + 450 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '先拆 session 签发:', time: C + 1050 } }, + { + event: { + type: 'tool.call.started', + turnId: 1, + toolCallId: 'call_01', + name: 'Edit', + args: { path: 'apps/web/src/auth/session.ts', old: '…(旧签发逻辑)…', new: '…(拆分后)…' }, + time: C + 1500, + }, + }, + { + event: { + type: 'cron.fired', + promptId: 'cron_01_p', + origin: { kind: 'cron_job', jobId: 'cron_01', cron: '0 9 * * 1-5', recurring: true, coalescedCount: 0, stale: false }, + prompt: '跑一遍登录相关测试并汇报结果', + time: C + 10000, + }, + }, + { event: { type: 'tool.result', turnId: 1, toolCallId: 'call_01', output: { applied: true }, time: C + 10500 } }, + { + event: { + type: 'turn.step.completed', + turnId: 1, + step: 1, + usage: { inputOther: 3400, output: 96, inputCacheRead: 15000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: C + 10600, + }, + }, + { event: { type: 'turn.step.started', turnId: 1, step: 2, time: C + 10650 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '定时任务到点了。先把重构收尾:', time: C + 11000 } }, + { + event: { + type: 'tool.call.started', + turnId: 1, + toolCallId: 'call_02', + name: 'Edit', + args: { path: 'apps/web/src/auth/index.ts', old: "export * from './session';", new: "export * from './session';\nexport * from './token';" }, + time: C + 11400, + }, + }, + { event: { type: 'tool.result', turnId: 1, toolCallId: 'call_02', output: { applied: true }, time: C + 11800 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '重构完成。现在执行定时任务——跑登录测试:', time: C + 12200 } }, + { + event: { type: 'tool.call.started', turnId: 1, toolCallId: 'call_03', name: 'Bash', args: { command: 'pnpm test -- login' }, time: C + 12600 }, + }, + { + event: { + type: 'tool.result', + turnId: 1, + toolCallId: 'call_03', + output: { stdout: 'Test Files 1 passed (1)\n Tests 6 passed (6)\n', exit_code: 0 }, + time: C + 14600, + }, + }, + { + event: { + type: 'assistant.delta', + turnId: 1, + delta: '都完成了:auth 重构收尾;定时任务已执行——登录测试 6/6 通过。', + time: C + 15000, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 1, + step: 2, + usage: { inputOther: 3600, output: 142, inputCacheRead: 16200, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: C + 15300, + }, + }, + { event: { type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 15383, time: C + 15400 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 10400, + usage: { total: { inputOther: 12800, output: 420, inputCacheRead: 38400, inputCacheCreation: 0 } }, + }, + time: C + 15420, + }, + }, + ], + }, + { + sectionLabel: 'task 完成 · 忙时(WS)', + steps: [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + turnId: 2, + content: [{ type: 'text', text: '把登录页按钮改成品牌色' }], + createdAt: '2026-09-04T10:30:00.000Z', + time: K + 10, + }, + }, + { event: { type: 'turn.started', turnId: 2, promptId: 'p_01', origin: { kind: 'user' }, time: K + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 2, step: 0, phase: 'running', since: K + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 10400, + maxContextTokens: 262144, + usage: { total: { inputOther: 12800, output: 420, inputCacheRead: 38400, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: K + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 2, step: 1, time: K + 22 } }, + { event: { type: 'thinking.delta', turnId: 2, delta: '改按钮样式,一处 CSS 变量即可。', time: K + 450 } }, + { event: { type: 'assistant.delta', turnId: 2, delta: '我来改按钮颜色:', time: K + 1050 } }, + { + event: { + type: 'tool.call.started', + turnId: 2, + toolCallId: 'call_01', + name: 'Edit', + args: { path: 'apps/web/src/styles/theme.css', old: '--btn-primary: #6b7280;', new: '--btn-primary: #4f46e5;' }, + time: K + 1500, + }, + }, + { + event: { + type: 'task.terminated', + info: { + taskId: 'task_01', + kind: 'shell', + status: 'completed', + description: 'pnpm build', + detached: true, + startedAt: '2026-09-04T10:30:02.000Z', + resultSummary: '构建成功:8 个包全部编译通过', + }, + outputTail: '… build finished successfully …', + time: K + 2000, + }, + }, + { + event: { + type: 'task.notified', + title: '后台任务完成', + body: '构建成功:8 个包全部编译通过', + severity: 'info', + notificationType: 'task.completed', + sourceKind: 'background_task', + sourceId: 'task_01', + time: K + 2010, + }, + }, + { event: { type: 'tool.result', turnId: 2, toolCallId: 'call_01', output: { applied: true }, time: K + 2500 } }, + { + event: { + type: 'assistant.delta', + turnId: 2, + delta: '按钮颜色改好了。顺便说,后台构建也完成了:8 个包全部编译通过。', + time: K + 2900, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 2, + step: 1, + usage: { inputOther: 3100, output: 92, inputCacheRead: 12800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: K + 3200, + }, + }, + { event: { type: 'turn.ended', turnId: 2, reason: 'completed', durationMs: 3283, time: K + 3300 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 11800, + usage: { total: { inputOther: 15900, output: 512, inputCacheRead: 51200, inputCacheCreation: 0 } }, + }, + time: K + 3320, + }, + }, + ], + }, + ]); + }); + + it('steer race:提交即收官,按新 turn 单实体补 emit', () => { + const S = Date.parse('2026-09-03T19:10:00.000Z'); + const actual = runScript('s_race', [ + { event: { type: 'prompt.submitted', promptId: 'p_01', status: 'running', content: [{ type: 'text', text: '主任务' }], createdAt: '2026-09-03T19:10:00.000Z', time: S } }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: S + 10 } }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: S + 20 } }, + { event: { type: 'prompt.submitted', promptId: 'p_02', status: 'queued', steer: true, content: [{ type: 'text', text: '插一句' }], createdAt: '2026-09-03T19:10:01.000Z', time: S + 1000 } }, + { event: { type: 'turn.step.completed', turnId: 0, step: 1, time: S + 2000 } }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 2000, time: S + 2010 } }, + { event: { type: 'prompt.completed', promptId: 'p_01', finishedAt: '2026-09-03T19:10:02.010Z', time: S + 2010 } }, + { event: { type: 'turn.started', turnId: 1, promptId: 'p_02', origin: { kind: 'user' }, time: S + 2020 } }, + { event: { type: 'prompt.started', promptId: 'p_02', time: S + 2021 } }, + { event: { type: 'turn.step.started', turnId: 1, step: 1, time: S + 2022 } }, + { event: { type: 'turn.step.completed', turnId: 1, step: 1, time: S + 3000 } }, + { event: { type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 1000, time: S + 3010 } }, + { event: { type: 'prompt.completed', promptId: 'p_02', finishedAt: '2026-09-03T19:10:03.010Z', time: S + 3010 } }, + ]); + for (const msg of actual) parseServerMessage(msg); + const steerFrames = actual.filter( + (m) => m.type === 'user' && (m as { text?: string }).text === '插一句', + ) as { message_id: string; status: string }[]; + expect(steerFrames.length).toBeGreaterThan(0); + for (const frame of steerFrames) expect(frame.message_id).toBe('t2.u0'); + expect(steerFrames[steerFrames.length - 1]?.status).toBe('completed'); + const firstSteerIdx = actual.indexOf(steerFrames[0] as (typeof actual)[number]); + const turnT2Idx = actual.findIndex((m) => m.type === 'turn' && (m as { turn_id?: string }).turn_id === 't2'); + expect(firstSteerIdx).toBeGreaterThan(turnT2Idx); + const t1Users = actual.filter( + (m) => m.type === 'user' && (m as { message_id?: string }).message_id?.startsWith('t1.'), + ) as { text?: string }[]; + for (const frame of t1Users) expect(frame.text).not.toBe('插一句'); + }); + + it('subagent 四场景', () => { + const B = Date.parse('2026-09-03T17:30:00.000Z'); + const spawnArgs = { description: '审查 LoginView 的白屏修复', agent_type: 'reviewer' }; + const reviewRefs = [{ agent_id: 'review_01', role: 'child' as const }]; + const startSteps = (thinkA: string, thinkB: string, say: string): ScriptStep[] => [ + { + event: { + type: 'prompt.submitted', + promptId: 'p_01', + status: 'running', + content: [{ type: 'text', text: '白屏修好了,帮我审查一下改动' }], + createdAt: '2026-09-03T17:30:00.000Z', + time: B + 10, + }, + }, + { event: { type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 0, step: 0, phase: 'running', since: B + 15 } }, + status: { + model: 'kimi-k3-highspeed', + contextTokens: 1820, + maxContextTokens: 262144, + usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, + }, + permission: 'manual', + time: B + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: thinkA, time: B + 450 } }, + { event: { type: 'thinking.delta', turnId: 0, delta: thinkB, time: B + 580 } }, + { event: { type: 'assistant.delta', turnId: 0, delta: say, time: B + 1050 } }, + ]; + const collectSteps = (at: number): ScriptStep[] => [ + { + event: { + type: 'task.terminated', + info: { taskId: 'task_01', kind: 'agent', status: 'completed', description: '审查 LoginView 的白屏修复', resultSummary: '审查通过,无回归风险' }, + outputTail: '审查通过:可选链修复正确,无回归风险。', + time: at, + }, + }, + { event: { type: 'turn.started', turnId: 1, origin: { kind: 'task', taskId: 'task_01' }, time: at + 10 } }, + { + facts: { + activity: { busy: true, mainTurnActive: true, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready', turn: { turnId: 1, step: 0, phase: 'running', since: at + 10 } }, + status: { + contextTokens: 4500, + usage: { total: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 } }, + }, + time: at + 20, + }, + }, + { event: { type: 'turn.step.started', turnId: 1, step: 1, time: at + 30 } }, + { event: { type: 'assistant.delta', turnId: 1, delta: '后台审查完成了:修复正确,无回归风险,可以放心提交。', time: at + 400 } }, + { + event: { + type: 'turn.step.completed', + turnId: 1, + step: 1, + usage: { inputOther: 2800, output: 58, inputCacheRead: 10600, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: at + 700, + }, + }, + { event: { type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 787, time: at + 800 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4800, + usage: { total: { inputOther: 5400, output: 122, inputCacheRead: 20400, inputCacheCreation: 0 } }, + }, + time: at + 810, + }, + }, + ]; + expectScenarios('subagent', [ + { + sectionLabel: 'A · 前台(主通道)', + steps: [ + ...startSteps('起个 reviewer 子代理独立审查,', '前台等它出结果。', '我起一个审查子代理,前台等它:'), + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Agent', + args: spawnArgs, + agentRefs: reviewRefs, + time: B + 1500, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { report: '审查通过:可选链修复正确,无回归风险。' }, + time: B + 4000, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2900, output: 72, inputCacheRead: 11000, inputCacheCreation: 0 }, + finishReason: 'tool_use', + time: B + 4100, + }, + }, + { event: { type: 'turn.step.started', turnId: 0, step: 2, time: B + 4150 } }, + { + event: { + type: 'assistant.delta', + turnId: 0, + delta: '子代理审查通过:修复正确,无回归风险,可以放心提交。', + time: B + 4500, + }, + }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 2, + usage: { inputOther: 3100, output: 80, inputCacheRead: 11800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4800, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4883, time: B + 4900 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 6200, + usage: { total: { inputOther: 6000, output: 152, inputCacheRead: 22800, inputCacheCreation: 0 } }, + }, + time: B + 4920, + }, + }, + ], + }, + { + sectionLabel: 'B · 后台(主通道)', + steps: [ + ...startSteps('起个后台子代理,', '结果回来再汇总。', '我起一个后台审查子代理:'), + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Agent', + args: { ...spawnArgs, run_in_background: true }, + agentRefs: reviewRefs, + time: B + 1500, + }, + }, + { event: { type: 'tool.result', turnId: 0, toolCallId: 'call_01', output: { task_id: 'task_01' }, time: B + 1600 } }, + { + event: { + type: 'task.started', + info: { + taskId: 'task_01', + kind: 'agent', + status: 'running', + description: '审查 LoginView 的白屏修复', + detached: true, + childAgentId: 'review_01', + }, + time: B + 1610, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: '子代理在后台审查,完成后我汇总。', time: B + 2000 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 2300, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 2383, time: B + 2400 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4500, + usage: { total: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 } }, + }, + time: B + 2420, + }, + }, + { + event: { + type: 'shell.output', + taskId: 'task_01', + update: { kind: 'stdout', text: '…正在读 LoginView 的改动…' }, + time: B + 3500, + }, + }, + ...collectSteps(B + 4100), + ], + }, + { + sectionLabel: 'C · 前台转后台(主通道)', + steps: [ + ...startSteps('先前台跑着,', '不行再转后台。', '我起一个审查子代理:'), + { + event: { + type: 'tool.call.started', + turnId: 0, + toolCallId: 'call_01', + name: 'Agent', + args: spawnArgs, + agentRefs: reviewRefs, + time: B + 1500, + }, + }, + { + event: { + type: 'task.started', + info: { + taskId: 'task_01', + kind: 'agent', + status: 'running', + description: '审查 LoginView 的白屏修复', + detached: true, + childAgentId: 'review_01', + outputTail: '…正在读 LoginView 的改动…', + }, + time: B + 3500, + }, + }, + { + event: { + type: 'tool.result', + turnId: 0, + toolCallId: 'call_01', + output: { detached: true, task_id: 'task_01' }, + time: B + 3510, + }, + }, + { event: { type: 'assistant.delta', turnId: 0, delta: '子代理转后台了,完成后我汇总。', time: B + 3800 } }, + { + event: { + type: 'turn.step.completed', + turnId: 0, + step: 1, + usage: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4100, + }, + }, + { event: { type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 4183, time: B + 4200 } }, + { + facts: { + activity: { busy: false, mainTurnActive: false, pendingInteraction: 'none' }, + agentActivity: { lifecycle: 'ready' }, + status: { + contextTokens: 4500, + usage: { total: { inputOther: 2600, output: 64, inputCacheRead: 9800, inputCacheCreation: 0 } }, + }, + time: B + 4220, + }, + }, + { + event: { + type: 'shell.output', + taskId: 'task_01', + update: { kind: 'stdout', text: '…正在读 LoginView 的改动…' }, + time: B + 5300, + }, + }, + ...collectSteps(B + 5900), + ], + }, + { + sectionLabel: '子代理通道(按需订阅)', + steps: [ + { event: { type: 'turn.started', agentId: 'review_01', turnId: 0, origin: { kind: 'task', taskId: 'task_01' }, time: B + 1600 } }, + { event: { type: 'turn.step.started', agentId: 'review_01', turnId: 0, step: 1, time: B + 1620 } }, + { event: { type: 'thinking.delta', agentId: 'review_01', turnId: 0, delta: '先读 LoginView 的改动,', time: B + 2000 } }, + { event: { type: 'thinking.delta', agentId: 'review_01', turnId: 0, delta: '重点看 token 处理。', time: B + 2200 } }, + { event: { type: 'assistant.delta', agentId: 'review_01', turnId: 0, delta: '我先读 LoginView 的改动。', time: B + 2800 } }, + { + event: { + type: 'tool.call.started', + agentId: 'review_01', + turnId: 0, + toolCallId: 'call_02', + name: 'Read', + args: { path: 'apps/web/src/views/LoginView.vue' }, + time: B + 3200, + }, + }, + { + event: { + type: 'tool.result', + agentId: 'review_01', + turnId: 0, + toolCallId: 'call_02', + output: { content: '', lines: 214 }, + time: B + 3800, + }, + }, + { + event: { + type: 'subagent.completed', + agentId: 'review_01', + subagentId: 'review_01', + resultSummary: '审查通过:可选链修复正确,无回归风险。', + time: B + 4500, + }, + }, + { + event: { + type: 'turn.step.completed', + agentId: 'review_01', + turnId: 0, + step: 1, + usage: { inputOther: 2200, output: 64, inputCacheRead: 8000, inputCacheCreation: 0 }, + finishReason: 'end_turn', + time: B + 4500, + }, + }, + { event: { type: 'turn.ended', agentId: 'review_01', turnId: 0, reason: 'completed', durationMs: 2997, time: B + 4600 } }, + ], + }, + ]); + }); +}); + +interface ColdRec { + type: string; + time?: number; + [key: string]: unknown; +} + +function rec(type: string, fields: Record, time: number): ColdRec { + return { type, ...fields, time }; +} + +function loop(event: Record, time: number): ColdRec { + return { type: 'context.append_loop_event', event, time }; +} + +function begin(uuid: string, turn: number, step: number, time: number): ColdRec { + return loop({ type: 'step.begin', uuid, turnId: String(turn), step }, time); +} + +function end(uuid: string, finishReason: string, usage: unknown, time: number): ColdRec { + return loop({ type: 'step.end', uuid, finishReason, usage }, time); +} + +function fail(uuid: string, time: number): ColdRec { + return loop({ type: 'step.end', uuid, finishReason: 'error' }, time); +} + +function think(uuid: string, text: string, time: number): ColdRec { + return loop({ type: 'content.part', stepUuid: uuid, part: { type: 'think', think: text } }, time); +} + +function say(uuid: string, text: string, time: number): ColdRec { + return loop({ type: 'content.part', stepUuid: uuid, part: { type: 'text', text } }, time); +} + +function call(uuid: string, toolCallId: string, name: string, args: unknown, time: number): ColdRec { + return loop({ type: 'tool.call', stepUuid: uuid, toolCallId, name, args }, time); +} + +function result(toolCallId: string, output: unknown, time: number, isError?: boolean): ColdRec { + return loop({ type: 'tool.result', toolCallId, result: { output, isError } }, time); +} + +function usageOf(inputOther: number, output: number, inputCacheRead = 0): Record { + return { inputOther, output, inputCacheRead, inputCacheCreation: 0 }; +} + +function promptAccepted(promptId: string, createdAt: string, time: number): ColdRec { + return rec('prompt.accepted', { promptId, createdAt }, time); +} + +const USER_ORIGIN = { kind: 'user' } as const; + +function turnPrompt(promptId: string, text: string, time: number, origin: unknown = USER_ORIGIN): ColdRec { + return rec('turn.prompt', { input: [{ type: 'text', text }], origin, promptId }, time); +} + +function promptCompleted(promptId: string, finishedAt: string, time: number): ColdRec { + return rec('prompt.completed', { promptId, finishedAt, reason: 'completed' }, time); +} + +function queryFromRequest(request: string): { beforeTurn?: string; afterStep?: string; pageSize?: number } { + const url = new URL(request, 'http://localhost'); + const query: { beforeTurn?: string; afterStep?: string; pageSize?: number } = {}; + const beforeTurn = url.searchParams.get('before_turn'); + const afterStep = url.searchParams.get('after_step'); + const pageSize = url.searchParams.get('page_size'); + if (beforeTurn) query.beforeTurn = beforeTurn; + if (afterStep) query.afterStep = afterStep; + if (pageSize) query.pageSize = Number(pageSize); + return query; +} + +function expectRestHistory(tabId: string, sectionLabel: string, records: ColdRec[]): void { + const tab = FIXTURES.tabs.find((t) => t.id === tabId); + if (!tab) throw new Error(`tab ${tabId} not found`); + const section = tab.sections.find((s) => s.label === sectionLabel) as + | (FixtureTab['sections'][number] & { request?: string }) + | undefined; + if (!section?.request) throw new Error(`REST section ${sectionLabel} not found in ${tabId}`); + const responses = section.items.map((it) => JSON.parse(it.json) as Record); + const sessionId = (responses[0] as { session_id?: string }).session_id ?? 's_01'; + const page = buildColdHistory(sessionId, 'main', records, queryFromRequest(section.request)); + for (const msg of page.items) parseServerMessage(msg); + expect(page).toEqual(responses[0]); + const secondNote = section.items[1]?.note; + if (responses[1] !== undefined && secondNote) { + const match = /GET\s+(\/\S+)/.exec(secondNote); + if (!match) throw new Error(`no request line in note of ${tabId}/${sectionLabel}#1`); + const page2 = buildColdHistory(sessionId, 'main', records, queryFromRequest(match[1]!)); + for (const msg of page2.items) parseServerMessage(msg); + expect(page2).toEqual(responses[1]); + } +} + +describe('v2Projection × REST 历史冷重建', () => { + it('basic REST 历史', () => { + const B = Date.parse('2026-09-03T10:00:00.000Z'); + expectRestHistory('basic', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T10:00:00.000Z', B + 10), + turnPrompt('p_01', '你好', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '用户在打招呼,', B + 450), + think('s1', '简短回应即可。', B + 700), + say('s1', '你好!', B + 1050), + say('s1', '有什么可以帮你的?', B + 1300), + end('s1', 'end_turn', usageOf(1820, 24), B + 1395), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1483 }, B + 1495), + promptCompleted('p_01', '2026-09-03T10:00:01.500Z', B + 1500), + ]); + }); + + it('tool REST 历史', () => { + const B = Date.parse('2026-09-03T11:00:00.000Z'); + expectRestHistory('tool', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T11:00:00.000Z', B + 10), + turnPrompt('p_01', '执行一下 ls', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '用户想看当前目录内容,用 Bash 执行 ls。', B + 600), + say('s1', '好的,执行 `ls`:', B + 800), + call('s1', 'call_01', 'Bash', { command: 'ls' }, B + 1150), + result('call_01', { stdout: 'apps\ndocs\npackages\npnpm-workspace.yaml\n', exit_code: 0 }, B + 1600), + end('s1', 'tool_use', usageOf(2100, 96), B + 1695), + begin('s2', 0, 2, B + 1895), + say('s2', '当前目录下有 4 个条目:', B + 2250), + say('s2', '`apps`、`docs`、`packages` 和 `pnpm-workspace.yaml`。', B + 2500), + end('s2', 'end_turn', usageOf(2240, 58), B + 2595), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 2683 }, B + 2695), + promptCompleted('p_01', '2026-09-03T11:00:02.700Z', B + 2700), + ]); + }); + + it('multi-tool REST 历史', () => { + const B = Date.parse('2026-09-03T12:00:00.000Z'); + expectRestHistory('multi-tool', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T12:00:00.000Z', B + 10), + turnPrompt('p_01', '写一个 hello.py 打印当前时间,加个 shebang,然后跑一下', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '先 Write 创建脚本,再 Edit 加 shebang,最后 Bash 运行。', B + 600), + say('s1', '我来创建 `hello.py`:', B + 800), + call('s1', 'call_01', 'Write', { path: 'hello.py', content: 'from datetime import datetime\nprint(datetime.now())\n' }, B + 1100), + result('call_01', { bytes_written: 52 }, B + 1400), + end('s1', 'tool_use', usageOf(2400, 130), B + 1495), + begin('s2', 0, 2, B + 1695), + say('s2', '补上 shebang:', B + 2000), + call('s2', 'call_02', 'Edit', { path: 'hello.py', old: 'from datetime import datetime', new: '#!/usr/bin/env python3\nfrom datetime import datetime' }, B + 2200), + result('call_02', { applied: true }, B + 2400), + end('s2', 'tool_use', usageOf(2580, 74), B + 2495), + begin('s3', 0, 3, B + 2695), + say('s3', '跑一下验证:', B + 2900), + call('s3', 'call_03', 'Bash', { command: 'python3 hello.py' }, B + 3100), + result('call_03', { stdout: '2026-09-03 12:00:03.587201\n', exit_code: 0 }, B + 3600), + end('s3', 'tool_use', usageOf(2720, 66), B + 3695), + begin('s4', 0, 4, B + 3895), + say('s4', '完成。`hello.py` 已创建并加上 shebang,', B + 4100), + say('s4', '运行输出当前时间,一切正常。', B + 4200), + end('s4', 'end_turn', usageOf(2830, 62), B + 4295), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 4383 }, B + 4395), + promptCompleted('p_01', '2026-09-03T12:00:04.400Z', B + 4400), + ]); + }); + + it('todo REST 历史', () => { + const B = Date.parse('2026-09-03T12:00:00.000Z'); + const todos1 = [ + { title: '查看登录页代码,定位白屏原因', status: 'in_progress' }, + { title: '修复崩溃', status: 'pending' }, + { title: '跑测试验证', status: 'pending' }, + ]; + const todos2 = [ + { title: '查看登录页代码,定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'in_progress' }, + { title: '跑测试验证', status: 'pending' }, + ]; + const todos3 = [ + { title: '查看登录页代码,定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'done' }, + { title: '跑测试验证', status: 'in_progress' }, + ]; + const todos4 = [ + { title: '查看登录页代码,定位白屏原因', status: 'done' }, + { title: '修复崩溃', status: 'done' }, + { title: '跑测试验证', status: 'done' }, + ]; + expectRestHistory('todo', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T12:00:00.000Z', B + 10), + turnPrompt('p_01', '登录页点登录直接白屏,修一下', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '白屏一般是运行时错误。分三步:', B + 450), + think('s1', '定位、修复、验证。', B + 700), + say('s1', '我分三步处理:', B + 1200), + call('s1', 'call_01', 'TodoWrite', { items: todos1 }, B + 1300), + result('call_01', { updated: true }, B + 1350), + call('s1', 'call_02', 'Read', { path: 'apps/web/src/views/LoginView.vue' }, B + 1500), + result('call_02', { content: '', lines: 214 }, B + 2200), + call('s1', 'call_03', 'TodoWrite', { items: todos2 }, B + 2300), + result('call_03', { updated: true }, B + 2350), + call('s1', 'call_04', 'Edit', { path: 'apps/web/src/views/LoginView.vue', old: 'const token = user.token;', new: 'const token = user?.token;' }, B + 2500), + result('call_04', { applied: true }, B + 3000), + end('s1', 'tool_use', usageOf(2900, 78, 10000), B + 3095), + begin('s2', 0, 2, B + 3145), + call('s2', 'call_05', 'TodoWrite', { items: todos3 }, B + 3300), + result('call_05', { updated: true }, B + 3350), + call('s2', 'call_06', 'Bash', { command: 'pnpm test -- login' }, B + 3500), + result('call_06', { stdout: 'Test Files 1 passed (1)\n Tests 6 passed (6)\n', exit_code: 0 }, B + 5500), + call('s2', 'call_07', 'TodoWrite', { items: todos4 }, B + 5600), + result('call_07', { updated: true }, B + 5650), + say('s2', '修好了:`handleLogin` 在 `user` 为空时直接读 `token` 导致白屏,', B + 6000), + say('s2', '已加可选链判空;登录相关 6 个测试全部通过。', B + 6500), + end('s2', 'end_turn', usageOf(2600, 84, 9500), B + 6595), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 6683 }, B + 6695), + promptCompleted('p_01', '2026-09-03T12:00:06.695Z', B + 6695), + ]); + }); + + it('queue-abort REST 历史', () => { + const B = Date.parse('2026-09-03T11:00:00.000Z'); + expectRestHistory('queue-abort', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T11:00:00.000Z', B + 10), + turnPrompt('p_01', '跑一下测试套件', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '测试命令免审批,直接跑。', B + 700), + say('s1', '我来跑测试:', B + 1200), + call('s1', 'call_01', 'Bash', { command: 'pnpm test' }, B + 1500), + result('call_01', { stdout: 'Test Files 1 failed | 3 passed (4)\n Tests 2 failed | 18 passed (20)\n', exit_code: 1 }, B + 6000), + end('s1', 'tool_use', usageOf(2600, 60, 9000), B + 6095), + begin('s2', 0, 2, B + 6145), + say('s2', '测试跑完了:18 通过、2 失败。', B + 6700), + end('s2', 'end_turn', usageOf(2800, 40, 11000), B + 6795), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 6883 }, B + 6895), + promptCompleted('p_01', '2026-09-03T11:00:06.895Z', B + 6895), + promptAccepted('p_02', '2026-09-03T11:00:04.000Z', B + 4010), + turnPrompt('p_02', '把失败的用例列出来', B + 6928), + begin('s3', 1, 1, B + 6948), + think('s3', '从刚才的输出里挑失败用例即可,', B + 7250), + think('s3', '不用重跑。', B + 7550), + say('s3', '失败的两个用例都在 `auth` 目录下:', B + 7900), + say('s3', '`login.spec.ts` 的「过期 token 应跳转登录页」、', B + 8600), + rec('turn.step.interrupted', { turnId: 1, step: 1, reason: 'aborted by user' }, B + 8605), + rec('turn.ended', { turnId: 1, reason: 'cancelled' }, B + 8615), + promptCompleted('p_02', '2026-09-03T11:00:08.615Z', B + 8615), + rec('prompt.aborted', { promptId: 'p_02', abortedAt: '2026-09-03T11:00:08.640Z' }, B + 8640), + ]); + }); + + it('question REST 历史', () => { + const B = Date.parse('2026-09-03T14:00:00.000Z'); + expectRestHistory('question', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T14:00:00.000Z', B + 10), + turnPrompt('p_01', '把 README 的安装命令更新成 pnpm', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '安装命令在快速开始和开发者文档各有一处。', B + 450), + think('s1', '范围不明,先问用户。', B + 700), + say('s1', 'README 里有两处安装命令,先确认范围:', B + 1200), + rec('interaction.request', { + id: 'q_01', + kind: 'question', + request: { + questions: [ + { id: 'q1', question: 'README 里有两处安装命令(快速开始、开发者文档),要都更新吗?', options: ['两处都改', '只改快速开始'] }, + ], + }, + }, B + 1500), + rec('interaction.resolved', { id: 'q_01', response: { answers: { q1: '两处都改' } } }, B + 5100), + call('s1', 'call_01', 'Edit', { path: 'README.md', old: 'npm install && npm run dev', new: 'pnpm install && pnpm dev' }, B + 5300), + result('call_01', { applied: true }, B + 5800), + end('s1', 'tool_use', usageOf(2300, 48, 7600), B + 5895), + begin('s2', 0, 2, B + 5945), + call('s2', 'call_02', 'Edit', { path: 'README.md', old: 'npm run build && npm start', new: 'pnpm build && pnpm start' }, B + 6100), + result('call_02', { applied: true }, B + 6500), + say('s2', '两处都改好了:快速开始和开发者文档的安装命令已更新为 `pnpm` 版。', B + 7100), + end('s2', 'end_turn', usageOf(2500, 62, 8800), B + 7195), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 7283 }, B + 7295), + promptCompleted('p_01', '2026-09-03T14:00:07.295Z', B + 7295), + ]); + }); + + it('tool-error REST 历史', () => { + const B = Date.parse('2026-09-03T15:00:00.000Z'); + expectRestHistory('tool-error', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T15:00:00.000Z', B + 10), + turnPrompt('p_01', '把配置里的超时时间改成 30 秒', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '超时配置在 config/server.toml,直接 Edit。', B + 700), + say('s1', '我来改超时配置:', B + 1200), + call('s1', 'call_01', 'Edit', { path: 'config/server.toml', old: 'timeout = 10', new: 'timeout = 30' }, B + 1500), + result('call_01', 'old_string 在文件中未找到', B + 1900, true), + end('s1', 'tool_use', usageOf(2300, 46, 7200), B + 1995), + begin('s2', 0, 2, B + 2045), + say('s2', '没匹配上,我先看下文件实际内容:', B + 2600), + call('s2', 'call_02', 'Read', { path: 'config/server.toml' }, B + 2800), + result('call_02', { content: '[server]\nrequest_timeout = 10\n…', lines: 18 }, B + 3200), + call('s2', 'call_03', 'Edit', { path: 'config/server.toml', old: 'request_timeout = 10', new: 'request_timeout = 30' }, B + 3400), + result('call_03', { applied: true }, B + 3800), + say('s2', '改好了:超时配置项是 `request_timeout`(不是 `timeout`),已从 10 改为 30。', B + 4400), + end('s2', 'end_turn', usageOf(2800, 82, 10400), B + 4495), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 4583 }, B + 4595), + promptCompleted('p_01', '2026-09-03T15:00:04.595Z', B + 4595), + ]); + }); + + it('llm-retry REST 历史', () => { + const B = Date.parse('2026-09-03T15:30:00.000Z'); + expectRestHistory('llm-retry', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T15:30:00.000Z', B + 10), + turnPrompt('p_01', '总结一下这个项目的目录结构', B + 12), + begin('s1', 0, 1, B + 20), + fail('s1', B + 500), + begin('s2', 0, 1, B + 2950), + think('s2', '先列顶层目录,', B + 2950), + think('s2', '再按功能分组说明。', B + 3300), + say('s2', '项目分四块:`apps/` 三个端(desktop、web、auth-login)、', B + 3700), + say('s2', '`packages/` 八个共享包、`scripts/` 构建发布脚本、`docs/` 设计文档。', B + 4100), + end('s2', 'end_turn', usageOf(2400, 62, 8600), B + 4195), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 4283 }, B + 4295), + promptCompleted('p_01', '2026-09-03T15:30:04.295Z', B + 4295), + ]); + }); + + it('background-task REST 历史', () => { + const B = Date.parse('2026-09-03T16:00:00.000Z'); + expectRestHistory('background-task', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T16:00:00.000Z', B + 10), + turnPrompt('p_01', '跑一下完整构建', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '完整构建要几分钟,先跑起来,太久就转后台。', B + 700), + say('s1', '我来跑完整构建:', B + 1200), + call('s1', 'call_01', 'Bash', { command: 'pnpm build' }, B + 1500), + rec('task.started', { info: { taskId: 'task_01', kind: 'shell', status: 'running', description: 'pnpm build', detached: true } }, B + 1500), + result('call_01', { detached: true, task_id: 'task_01' }, B + 4010), + end('s1', 'tool_use', usageOf(2500, 58, 9200), B + 4095), + begin('s2', 0, 2, B + 4145), + say('s2', '构建量比较大,已转后台跑(task_01),完成后我告诉你。', B + 4700), + end('s2', 'end_turn', usageOf(2700, 66, 10100), B + 4795), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 4883 }, B + 4895), + promptCompleted('p_01', '2026-09-03T16:00:04.895Z', B + 4895), + rec('task.terminated', { + info: { taskId: 'task_01', kind: 'shell', status: 'completed', description: 'pnpm build', resultSummary: '构建成功:8 个包全部编译通过', endedAt: '2026-09-03T16:07:58.495Z' }, + outputTail: '… build finished successfully in 7m 56s …', + }, B + 478500), + rec('turn.prompt', { + input: [{ type: 'text', text: '\nTitle: 后台构建完成\nSeverity: info\n构建成功:8 个包全部编译通过\n' }], + origin: { kind: 'task', taskId: 'task_01' }, + promptId: 'p_02', + }, B + 478598), + begin('s3', 1, 1, B + 478618), + say('s3', '构建完成了:8 个包全部编译通过,产物在各自的 `dist/`。', B + 479200), + end('s3', 'end_turn', usageOf(2900, 44, 12000), B + 479295), + rec('turn.ended', { turnId: 1, reason: 'completed', durationMs: 797 }, B + 479395), + ]); + }); + + it('compaction REST 历史', () => { + const B = Date.parse('2026-09-03T16:30:00.000Z'); + const records: ColdRec[] = []; + for (let i = 0; i < 8; i++) { + records.push( + rec('turn.prompt', { input: [{ type: 'text', text: `第 ${i + 1} 轮讨论` }], origin: { kind: 'user' }, promptId: `old_${i}` }, B - 20000 + i * 1000), + rec('turn.ended', { turnId: i, reason: 'completed', durationMs: 900 }, B - 19500 + i * 1000), + ); + } + records.push( + promptAccepted('p_01', '2026-09-03T16:30:00.000Z', B + 10), + turnPrompt('p_01', '接着上面的讨论,把新页面的路由也加上', B + 12), + rec('context.apply_compaction', { summary: '前 8 轮讨论摘要', compactedCount: 40, tokensBefore: 241000, tokensAfter: 62000 }, B + 100), + begin('s1', 8, 1, B + 118), + think('s1', '路由集中在 router 配置文件,加一条即可。', B + 800), + say('s1', '我来加路由:', B + 1300), + call('s1', 'call_01', 'Edit', { + path: 'apps/web/src/router.ts', + old: " { path: '/login', component: LoginView },", + new: " { path: '/login', component: LoginView },\n { path: '/new-page', component: NewPageView },", + }, B + 1500), + result('call_01', { applied: true }, B + 1900), + say('s1', '加好了:`/new-page` 路由已注册到 `router.ts`。', B + 2500), + end('s1', 'end_turn', usageOf(3100, 72, 9600), B + 2595), + rec('turn.ended', { turnId: 8, reason: 'completed', durationMs: 2683 }, B + 2695), + promptCompleted('p_01', '2026-09-03T16:30:02.695Z', B + 2695), + ); + expectRestHistory('compaction', 'REST 历史', records); + }); + + it('undo REST 历史', () => { + const B = Date.parse('2026-09-03T18:10:00.000Z'); + expectRestHistory('undo', 'REST 历史', [ + promptAccepted('p_01', '2026-09-03T18:10:00.000Z', B + 10), + turnPrompt('p_01', '把配置里的超时改成 30 秒', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '超时配置在 config/server.toml,直接 Edit。', B + 700), + say('s1', '我来改超时配置:', B + 1200), + call('s1', 'call_01', 'Edit', { path: 'config/server.toml', old: 'request_timeout = 10', new: 'request_timeout = 30' }, B + 1500), + result('call_01', { applied: true }, B + 1800), + say('s1', '改好了:`request_timeout` 已从 10 改为 30。', B + 4400), + end('s1', 'end_turn', usageOf(5100, 128, 17600), B + 4495), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 4583 }, B + 4595), + promptCompleted('p_01', '2026-09-03T18:10:04.595Z', B + 4595), + rec('context.undo', { count: 1 }, B + 4990), + rec('context.undone', { turns: 1, fromTurnId: 0 }, B + 5000), + ]); + }); + + it('approval REST 历史(A)', () => { + const B = Date.parse('2026-09-03T10:00:00.000Z'); + expectRestHistory('approval', 'REST 历史(A)', [ + promptAccepted('p_01', '2026-09-03T10:00:00.000Z', B + 10), + turnPrompt('p_01', '把登录页崩溃的复现脚本跑一下', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '复现脚本在 `scripts/` 下,node 执行需要审批。', B + 700), + say('s1', '我来跑一下复现脚本:', B + 1200), + call('s1', 'call_01', 'Bash', { command: 'node scripts/repro-login-crash.mjs' }, B + 1500), + rec('interaction.request', { + id: 'ap_01', + kind: 'approval', + toolCallId: 'call_01', + request: { tool_name: 'Bash', input: { command: 'node scripts/repro-login-crash.mjs' }, reason: '运行脚本需要执行权限' }, + }, B + 1510), + rec('interaction.resolved', { id: 'ap_01', response: { decision: 'approved' } }, B + 5100), + result('call_01', { stdout: "TypeError: Cannot read properties of undefined (reading 'token')\n at handleLogin (LoginView.vue:87)\n", exit_code: 1 }, B + 6200), + end('s1', 'tool_use', usageOf(2400, 52, 8000), B + 6295), + begin('s2', 0, 2, B + 6345), + say('s2', '复现成功,报错和浏览器里看到的一致:`handleLogin` 读取了 undefined 的 `token` 字段(`LoginView.vue:87`)。', B + 7100), + end('s2', 'end_turn', usageOf(3100, 88, 12800), B + 7195), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 7283 }, B + 7295), + promptCompleted('p_01', '2026-09-03T10:00:07.295Z', B + 7295), + ]); + }); + + it('recovery REST 历史与补尾巴', () => { + const T1 = Date.parse('2026-09-03T10:30:00.000Z'); + const T2 = Date.parse('2026-09-03T11:00:00.000Z'); + const records: ColdRec[] = [ + promptAccepted('p_01', '2026-09-03T10:30:00.000Z', T1 + 10), + turnPrompt('p_01', '这个 CLI 的入口文件是哪个?', T1 + 12), + begin('s1', 0, 1, T1 + 20), + think('s1', '入口是 src/cli.ts,顺带说明参数解析与子命令分发。', T1 + 800), + say('s1', '入口是 `src/cli.ts`:全局参数在这里解析,再分发到 `build`、`dev`、`test` 三个子命令。', T1 + 1400), + end('s1', 'end_turn', usageOf(2400, 58), T1 + 1495), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1603 }, T1 + 1615), + promptCompleted('p_01', '2026-09-03T10:30:01.615Z', T1 + 1615), + promptAccepted('p_02', '2026-09-03T11:00:00.000Z', T2 + 10), + turnPrompt('p_02', '我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?', T2 + 12), + begin('s2', 1, 1, T2 + 20), + think('s2', '先读入口文件的参数解析部分,再给方案建议。', T2 + 700), + say('s2', '我看一下 `src/cli.ts` 的参数解析实现:', T2 + 1200), + call('s2', 'call_01', 'Read', { path: 'src/cli.ts' }, T2 + 1500), + result('call_01', { content: '…(文件内容,含 parseArgs 实现)…', lines: 86 }, T2 + 1900), + end('s2', 'tool_use', usageOf(2800, 64, 8000), T2 + 1995), + begin('s3', 1, 2, T2 + 2050), + say('s3', '建议加在 `parseArgs` 的全局区,', T2 + 2300), + ]; + expectRestHistory('recovery', 'A · 刷新:REST 历史', records); + expectRestHistory('recovery', 'B · 重连:REST 补尾巴', records); + }); + + it('history 路由:注册、响应包络、schema 校验与 404', async () => { + const B = Date.parse('2026-09-03T10:00:00.000Z'); + const records: ColdRec[] = [ + promptAccepted('p_01', '2026-09-03T10:00:00.000Z', B + 10), + turnPrompt('p_01', '你好', B + 12), + begin('s1', 0, 1, B + 20), + think('s1', '用户在打招呼,', B + 450), + think('s1', '简短回应即可。', B + 700), + say('s1', '你好!', B + 1050), + say('s1', '有什么可以帮你的?', B + 1300), + end('s1', 'end_turn', usageOf(1820, 24), B + 1395), + rec('turn.ended', { turnId: 0, reason: 'completed', durationMs: 1483 }, B + 1495), + promptCompleted('p_01', '2026-09-03T10:00:01.500Z', B + 1500), + ]; + const captureHost = () => { + const captured: { path?: string; handler?: (req: unknown, reply: unknown) => Promise } = {}; + const app = { + get(path: string, _options: unknown, handler: (req: unknown, reply: unknown) => Promise) { + captured.path = path; + captured.handler = handler; + }, + }; + return { app: app as Parameters[0], captured }; + }; + const { app, captured } = captureHost(); + registerHistoryRoutes(app, { transcript: { readColdWireRecords: async () => records } }); + expect(captured.path).toBe('/sessions/:session_id/history'); + const handler = captured.handler!; + const reply = { payload: undefined as unknown, send(p: unknown) { this.payload = p; return p; } }; + await handler({ id: 'req_01', params: { session_id: 's_01' }, query: { page_size: 50 } }, reply); + const tab = FIXTURES.tabs.find((t) => t.id === 'basic')!; + const restSection = tab.sections.find((s) => s.label === 'REST 历史')!; + const expected = JSON.parse(restSection.items[0]!.json) as Record; + expect(reply.payload).toEqual({ code: 0, msg: 'success', data: expected, request_id: 'req_01' }); + expect(historyResponseSchema.parse((reply.payload as { data: unknown }).data)).toEqual(expected); + + const { app: app404, captured: captured404 } = captureHost(); + registerHistoryRoutes(app404, { transcript: { readColdWireRecords: async () => undefined } }); + const reply404 = { payload: undefined as unknown, send(p: unknown) { this.payload = p; return p; } }; + await captured404.handler!({ id: 'req_02', params: { session_id: 's_xx' }, query: {} }, reply404); + expect(reply404.payload).toMatchObject({ code: 40401, data: null, request_id: 'req_02' }); + }); +}); + +class FakeWsSocket { + readonly sent: string[] = []; + flush = true; + closed = false; + closeCalls: { code?: number; reason?: string }[] = []; + private readonly handlers = new Map void)[]>(); + + on(event: string, handler: (...args: unknown[]) => void): this { + const list = this.handlers.get(event) ?? []; + list.push(handler); + this.handlers.set(event, list); + return this; + } + + private fire(event: string, ...args: unknown[]): void { + for (const handler of [...(this.handlers.get(event) ?? [])]) handler(...args); + } + + deliver(data: unknown): void { + this.fire('message', data); + } + + send(data: string, cb?: () => void): void { + this.sent.push(data); + if (this.flush && cb) queueMicrotask(cb); + } + + ping(): void {} + + close(code?: number, reason?: string): void { + if (this.closed) return; + this.closed = true; + this.closeCalls.push({ code, reason }); + this.fire('close'); + } +} + +type FakeBusEvent = { type: string } & Record; + +class FakeAgentBus { + private readonly handlers: ((event: FakeBusEvent) => void)[] = []; + + subscribe(handler: (event: FakeBusEvent) => void): { dispose(): void } { + this.handlers.push(handler); + return { + dispose: () => { + const index = this.handlers.indexOf(handler); + if (index >= 0) this.handlers.splice(index, 1); + }, + }; + } + + emit(event: FakeBusEvent): void { + for (const handler of [...this.handlers]) handler(event); + } +} + +interface FakeActivityState { + busy: boolean; + mainTurnActive: boolean; + pendingInteraction: 'none' | 'approval' | 'question'; +} + +interface FakeActivitySource { + state(): FakeActivityState; + set(state: FakeActivityState, time?: number): void; +} + +function makeFakeSession(sessionId: string, agentIds: string[]): { + source: V2SessionSource; + buses: Map; + activity: FakeActivitySource; +} { + const buses = new Map(); + for (const agentId of agentIds) buses.set(agentId, new FakeAgentBus()); + let activityState: FakeActivityState = { busy: false, mainTurnActive: false, pendingInteraction: 'none' }; + const activityListeners = new Set<(state: FakeActivityState, time?: number) => void>(); + const activity: FakeActivitySource = { + state: () => activityState, + set: (state, time) => { + activityState = state; + for (const listener of [...activityListeners]) listener(activityState, time); + }, + }; + const source: V2SessionSource = { + sessionId, + agents: () => + agentIds.map((agentId) => ({ + agentId, + bus: { subscribe: (handler: (event: FakeBusEvent) => void) => buses.get(agentId)!.subscribe(handler) }, + permissionMode: () => 'manual' as const, + })), + agentFor: (agentId) => { + const bus = buses.get(agentId); + if (!bus) return undefined; + return { + agentId, + bus: { subscribe: (handler: (event: FakeBusEvent) => void) => bus.subscribe(handler) }, + permissionMode: () => 'manual' as const, + }; + }, + activity: { + state: () => activityState, + onDidChange: (handler: (state: FakeActivityState, time?: number) => void) => { + activityListeners.add(handler); + return { dispose: () => activityListeners.delete(handler) }; + }, + }, + }; + return { source, buses, activity }; +} + +function stepper(start: number): () => number { + let t = start; + return () => (t += 1); +} + +interface WsScenario { + binding: SessionV2Binding; + buses: Map; + activity: FakeActivitySource; + connect(opts?: { flush?: boolean; outboundCapacity?: number; inflightWindow?: number; globalFanout?: GlobalV2Fanout }): { + socket: FakeWsSocket; + frames: () => Record[]; + }; +} + +function makeV2Scenario(opts: { sessionId: string; agentIds?: string[]; clock?: () => number }): WsScenario { + const agentIds = opts.agentIds ?? ['main']; + const { source, buses, activity } = makeFakeSession(opts.sessionId, agentIds); + const binder = new SessionV2Binder(opts.clock ?? Date.now); + const binding = binder.attach(source); + const registry = new ConnectionRegistry(); + return { + binding, + buses, + activity, + connect: (connectOpts) => { + const socket = new FakeWsSocket(); + if (connectOpts?.flush === false) socket.flush = false; + const connection = new WsConnectionV2({ + socket: socket as never, + binder, + registry, + serverId: 'srv_9f2c', + sessionSourceFor: () => source, + clock: opts.clock ?? Date.now, + outboundCapacity: connectOpts?.outboundCapacity, + inflightWindow: connectOpts?.inflightWindow, + globalFanout: connectOpts?.globalFanout, + heartbeatIntervalMs: 0, + }); + void connection; + return { + socket, + frames: () => socket.sent.map((line) => JSON.parse(line) as Record), + }; + }, + }; +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function fixtureSection(tabId: string, label: string): Record[] { + const tab = FIXTURES.tabs.find((t) => t.id === tabId); + if (!tab) throw new Error(`tab ${tabId} not found`); + const section = tab.sections.find((s) => s.label === label); + if (!section) throw new Error(`section ${label} not found in ${tabId}`); + return section.items.map((it) => JSON.parse(it.json) as Record); +} + +const HELLO_FRAME = { type: 'hello', protocol_version: 2, server_id: 'srv_9f2c', capabilities: ['step_replay_v1', 'interaction_v1'] }; + +describe('WS v2 传输层', () => { + it('recovery A:刷新恢复与直播', async () => { + const T2 = Date.parse('2026-09-03T11:00:00.000Z'); + const clock = stepper(T2 + 3627); + const scenario = makeV2Scenario({ sessionId: 's_04', clock }); + const { buses, activity } = scenario; + const { socket, frames } = scenario.connect(); + const drive = (event: FakeBusEvent) => buses.get('main')!.emit(event); + activity.set({ busy: true, mainTurnActive: true, pendingInteraction: 'none' }); + drive({ type: 'prompt.submitted', promptId: 'p_02', status: 'running', turnId: 1, content: [{ type: 'text', text: '我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?' }], createdAt: '2026-09-03T11:00:00.000Z', time: T2 + 10 }); + drive({ type: 'turn.started', turnId: 1, promptId: 'p_02', origin: { kind: 'user' }, time: T2 + 15 }); + drive({ type: 'turn.step.started', turnId: 1, step: 1, time: T2 + 22 }); + drive({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_01', name: 'Read', args: { path: 'src/cli.ts' }, time: T2 + 1500 }); + drive({ type: 'tool.result', turnId: 1, toolCallId: 'call_01', output: { content: '…(文件内容,含 parseArgs 实现)…', lines: 86 }, time: T2 + 1900 }); + drive({ type: 'turn.step.completed', turnId: 1, step: 1, usage: { inputOther: 2800, output: 64, inputCacheRead: 8000, inputCacheCreation: 0 }, finishReason: 'tool_use', time: T2 + 2000 }); + drive({ type: 'turn.step.started', turnId: 1, step: 2, time: T2 + 2050 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '建议加在入口的', time: T2 + 2400 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '全局参数解析处:', time: T2 + 2700 }); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', turn: { turnId: 1, step: 2, phase: 'running', since: 1788433200015 }, time: T2 + 2060 }); + drive({ type: 'agent.status.updated', model: 'kimi-k3-highspeed', contextTokens: 8100, maxContextTokens: 262144, usage: { currentTurn: { inputOther: 5700, output: 150, inputCacheRead: 20000, inputCacheCreation: 0 }, total: { inputOther: 8100, output: 208, inputCacheRead: 20000, inputCacheCreation: 0 } }, time: T2 + 2070 }); + await settle(); + socket.deliver(JSON.stringify({ type: 'subscribe', id: 1, session_id: 's_04' })); + drive({ type: 'assistant.delta', turnId: 1, delta: '`src/cli.ts` 的 `parseArgs` 里注册 `--verbose` 全局选项,', time: T2 + 4100 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '子命令自动继承;日志模块读到该标志后调到 debug 级别。', time: T2 + 4600 }); + drive({ type: 'turn.step.completed', turnId: 1, step: 2, usage: { inputOther: 3100, output: 96, inputCacheRead: 12800, inputCacheCreation: 0 }, finishReason: 'end_turn', time: T2 + 5100 }); + drive({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 5183, time: T2 + 5200 }); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', time: T2 + 5220 }); + drive({ type: 'agent.status.updated', contextTokens: 8500, usage: { total: { inputOther: 8300, output: 218, inputCacheRead: 20800, inputCacheCreation: 0 } }, time: T2 + 5220 }); + activity.set({ busy: false, mainTurnActive: false, pendingInteraction: 'none' }, T2 + 5220); + await settle(); + + const got = frames(); + expect(got[0]).toEqual(HELLO_FRAME); + const expected = fixtureSection('recovery', 'A · 刷新:WS 恢复与直播').slice(2); + for (const frame of got.slice(1)) parseServerMessage(frame); + expect(got.slice(1)).toEqual(expected); + }); + + it('recovery B:重连恢复与收官补发', async () => { + const T2 = Date.parse('2026-09-03T11:00:00.000Z'); + const clock = stepper(T2 + 5607); + const scenario = makeV2Scenario({ sessionId: 's_04', clock }); + const { buses, activity } = scenario; + const { socket, frames } = scenario.connect(); + const drive = (event: FakeBusEvent) => buses.get('main')!.emit(event); + activity.set({ busy: true, mainTurnActive: true, pendingInteraction: 'none' }); + drive({ type: 'prompt.submitted', promptId: 'p_02', status: 'running', turnId: 1, content: [{ type: 'text', text: '我想给 CLI 加一个全局 `--verbose` 选项,加在哪里比较合适?' }], createdAt: '2026-09-03T11:00:00.000Z', time: T2 + 10 }); + drive({ type: 'turn.started', turnId: 1, promptId: 'p_02', origin: { kind: 'user' }, time: T2 + 15 }); + drive({ type: 'turn.step.started', turnId: 1, step: 1, time: T2 + 22 }); + drive({ type: 'tool.call.started', turnId: 1, toolCallId: 'call_01', name: 'Read', args: { path: 'src/cli.ts' }, time: T2 + 1500 }); + drive({ type: 'tool.result', turnId: 1, toolCallId: 'call_01', output: { content: '…(文件内容,含 parseArgs 实现)…', lines: 86 }, time: T2 + 1900 }); + drive({ type: 'turn.step.completed', turnId: 1, step: 1, usage: { inputOther: 2800, output: 64, inputCacheRead: 8000, inputCacheCreation: 0 }, finishReason: 'tool_use', time: T2 + 2000 }); + drive({ type: 'turn.step.started', turnId: 1, step: 2, time: T2 + 2050 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '建议加在入口的', time: T2 + 2400 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '全局参数解析处:', time: T2 + 2700 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '`src/cli.ts` 的 `parseArgs` 里注册 `--verbose` 全局选项,', time: T2 + 4100 }); + drive({ type: 'assistant.delta', turnId: 1, delta: '子命令自动继承;日志模块读到该标志后调到 debug 级别。', time: T2 + 4600 }); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', turn: { turnId: 1, step: 2, phase: 'running', since: 1788433200015 }, time: T2 + 2060 }); + drive({ type: 'agent.status.updated', model: 'kimi-k3-highspeed', contextTokens: 8300, maxContextTokens: 262144, usage: { currentTurn: { inputOther: 5850, output: 156, inputCacheRead: 20600, inputCacheCreation: 0 }, total: { inputOther: 8250, output: 214, inputCacheRead: 20600, inputCacheCreation: 0 } }, time: T2 + 5600 }); + await settle(); + socket.deliver(JSON.stringify({ type: 'subscribe', id: 1, session_id: 's_04' })); + drive({ type: 'turn.step.completed', turnId: 1, step: 2, usage: { inputOther: 3100, output: 96, inputCacheRead: 12800, inputCacheCreation: 0 }, finishReason: 'end_turn', endedAt: T2 + 5100, time: T2 + 6400 }); + drive({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 6483, endedAt: T2 + 5200, time: T2 + 6500 }); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', time: T2 + 6520 }); + drive({ type: 'agent.status.updated', contextTokens: 8500, usage: { total: { inputOther: 8300, output: 218, inputCacheRead: 20800, inputCacheCreation: 0 } }, time: T2 + 6520 }); + activity.set({ busy: false, mainTurnActive: false, pendingInteraction: 'none' }, T2 + 6520); + await settle(); + + const got = frames(); + expect(got[0]).toEqual(HELLO_FRAME); + const expected = fixtureSection('recovery', 'B · 重连:WS 恢复与直播').slice(2); + for (const frame of got.slice(1)) parseServerMessage(frame); + expect(got.slice(1)).toEqual(expected); + }); + + it('connection:omit 订阅屏蔽 delta 族', async () => { + const B = Date.parse('2026-09-03T17:20:00.000Z'); + const scenario = makeV2Scenario({ sessionId: 's_15' }); + const { buses, activity } = scenario; + const { socket, frames } = scenario.connect(); + const drive = (event: FakeBusEvent) => buses.get('main')!.emit(event); + socket.deliver(JSON.stringify({ type: 'subscribe', id: 1, session_id: 's_15', omit: ['assistant.delta', 'thinking.delta', 'tool_call.delta'] })); + drive({ type: 'prompt.submitted', promptId: 'p_01', status: 'running', content: [{ type: 'text', text: '今天天气怎么样' }], createdAt: '2026-09-03T17:20:00.000Z', time: B + 10 }); + drive({ type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 }); + activity.set({ busy: true, mainTurnActive: true, pendingInteraction: 'none' }, B + 20); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', turn: { turnId: 0, step: 1, phase: 'running', since: 1788456000015 }, time: B + 20 }); + drive({ type: 'agent.status.updated', model: 'kimi-k3-highspeed', contextTokens: 1820, maxContextTokens: 262144, usage: { total: { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 } }, time: B + 20 }); + await settle(); + drive({ type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 }); + drive({ type: 'thinking.delta', turnId: 0, delta: '闲聊类问题,直接友好回答。', time: B + 300 }); + drive({ type: 'assistant.delta', turnId: 0, delta: '我没法查实时天气——告诉你城市的话,我可以聊聊一般的气候特点。', time: B + 900 }); + drive({ type: 'turn.step.completed', turnId: 0, step: 1, usage: { inputOther: 1820, output: 28, inputCacheRead: 0, inputCacheCreation: 0 }, finishReason: 'end_turn', time: B + 1400 }); + drive({ type: 'turn.ended', turnId: 0, reason: 'completed', durationMs: 1483, time: B + 1500 }); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', time: B + 1520 }); + drive({ type: 'agent.status.updated', contextTokens: 1870, usage: { total: { inputOther: 1820, output: 28, inputCacheRead: 0, inputCacheCreation: 0 } }, time: B + 1520 }); + activity.set({ busy: false, mainTurnActive: false, pendingInteraction: 'none' }, B + 1520); + await settle(); + + const got = frames(); + expect(got[0]).toEqual(HELLO_FRAME); + expect(got[1]).toEqual({ type: 'ack', id: 1, code: 0 }); + const expected = fixtureSection('connection', 'omit 订阅(WS)').slice(3); + for (const frame of got.slice(2)) parseServerMessage(frame); + expect(got.slice(2)).toEqual(expected); + expect(got.some((frame) => String(frame['type']).endsWith('.delta'))).toBe(false); + }); + + it('connection:背压溢出断开与重连恢复', async () => { + const B = Date.parse('2026-09-03T17:20:00.000Z'); + const scenario = makeV2Scenario({ sessionId: 's_15' }); + const first = scenario.connect({ flush: false, outboundCapacity: 2, inflightWindow: 1 }); + const drive = (event: FakeBusEvent) => scenario.buses.get('main')!.emit(event); + first.socket.deliver(JSON.stringify({ type: 'subscribe', id: 1, session_id: 's_15' })); + drive({ type: 'prompt.submitted', promptId: 'p_01', status: 'running', content: [{ type: 'text', text: '今天天气怎么样' }], createdAt: '2026-09-03T17:20:00.000Z', time: B + 10 }); + drive({ type: 'turn.started', turnId: 0, promptId: 'p_01', origin: { kind: 'user' }, time: B + 15 }); + drive({ type: 'turn.step.started', turnId: 0, step: 1, time: B + 22 }); + expect(first.socket.closed).toBe(true); + const last = JSON.parse(first.socket.sent.at(-1)!) as Record; + expect(last).toEqual({ type: 'error', code: 'backpressure_overflow', msg: 'outbound queue overflow; connection closed, reconnect to resync' }); + + const second = scenario.connect(); + const frames2 = second.frames; + expect(frames2()[0]).toEqual(HELLO_FRAME); + second.socket.deliver(JSON.stringify({ type: 'subscribe', id: 1, session_id: 's_15' })); + expect(frames2()[1]).toEqual({ type: 'ack', id: 1, code: 0 }); + const recovered = frames2().slice(2); + for (const frame of recovered) parseServerMessage(frame); + expect(recovered.map((frame) => [frame['type'], frame['step_id'] ?? frame['turn_id'] ?? ''])).toEqual([ + ['turn', 't1'], + ['step', 't1.0'], + ]); + expect(recovered[1]).toMatchObject({ state: 'running' }); + const again = scenario.binding.recoveryFor('main').filter((frame) => frame.type !== 'session.state'); + expect(again.map((frame) => [frame.type, (frame as { step_id?: string }).step_id ?? (frame as { turn_id?: string }).turn_id ?? ''])).toEqual([ + ['turn', 't1'], + ['step', 't1.0'], + ]); + }); + + it('subagent:子代理通道按需订阅', async () => { + const B = Date.parse('2026-09-03T17:30:00.000Z'); + const scenario = makeV2Scenario({ sessionId: 's_16', agentIds: ['main', 'review_01'] }); + const { buses } = scenario; + const { socket, frames } = scenario.connect(); + const drive = (event: FakeBusEvent) => buses.get('review_01')!.emit(event); + socket.deliver(JSON.stringify({ type: 'subscribe', id: 2, session_id: 's_16', agent_id: 'review_01' })); + drive({ type: 'turn.started', agentId: 'review_01', turnId: 0, origin: { kind: 'task', taskId: 'task_01' }, time: B + 1600 }); + drive({ type: 'turn.step.started', agentId: 'review_01', turnId: 0, step: 1, time: B + 1620 }); + drive({ type: 'thinking.delta', agentId: 'review_01', turnId: 0, delta: '先读 LoginView 的改动,', time: B + 2000 }); + drive({ type: 'thinking.delta', agentId: 'review_01', turnId: 0, delta: '重点看 token 处理。', time: B + 2200 }); + drive({ type: 'assistant.delta', agentId: 'review_01', turnId: 0, delta: '我先读 LoginView 的改动。', time: B + 2800 }); + drive({ type: 'tool.call.started', agentId: 'review_01', turnId: 0, toolCallId: 'call_02', name: 'Read', args: { path: 'apps/web/src/views/LoginView.vue' }, time: B + 3200 }); + drive({ type: 'tool.result', agentId: 'review_01', turnId: 0, toolCallId: 'call_02', output: { content: '', lines: 214 }, time: B + 3800 }); + drive({ type: 'subagent.completed', agentId: 'review_01', subagentId: 'review_01', resultSummary: '审查通过:可选链修复正确,无回归风险。', time: B + 4500 }); + drive({ type: 'turn.step.completed', agentId: 'review_01', turnId: 0, step: 1, usage: { inputOther: 2200, output: 64, inputCacheRead: 8000, inputCacheCreation: 0 }, finishReason: 'end_turn', time: B + 4500 }); + drive({ type: 'turn.ended', agentId: 'review_01', turnId: 0, reason: 'completed', durationMs: 2997, time: B + 4600 }); + await settle(); + + const got = frames(); + expect(got[0]).toEqual(HELLO_FRAME); + const section = fixtureSection('subagent', '子代理通道(按需订阅)'); + expect(got[1]).toEqual(section[1]); + const expected = section.slice(2); + for (const frame of got.slice(2)) parseServerMessage(frame); + expect(got.slice(2)).toEqual(expected); + }); +}); + +interface FanoutHarness { + emit(event: GlobalV2Event): void; + frames: ServerMessage[]; + fanout: GlobalV2Fanout; +} + +function makeFanoutHarness(opts: { + sessionInfoFor?: (sessionId: string) => Promise; + workspaceWireFor?: (workspace: unknown) => Promise; + clock?: () => number; +}): FanoutHarness { + const handlers: ((event: GlobalV2Event) => void)[] = []; + const frames: ServerMessage[] = []; + const fanout = new GlobalV2Fanout( + { + subscribe: (handler) => { + handlers.push(handler); + return { dispose: () => {} }; + }, + }, + { + sessionInfoFor: opts.sessionInfoFor ?? (async () => undefined), + workspaceWireFor: opts.workspaceWireFor ?? (async () => undefined), + clock: opts.clock ?? Date.now, + }, + ); + fanout.addTarget((msg) => frames.push(msg)); + return { + emit: (event) => { + for (const handler of [...handlers]) handler(event); + }, + frames, + fanout, + }; +} + +describe('WS v2 全消息面', () => { + it('session-changes 自动标题', async () => { + const harness = makeFanoutHarness({ + sessionInfoFor: async (sessionId) => ({ + session_id: sessionId, + workspace_id: 'ws_01', + title: '修复登录页白屏', + status: 'active', + model: 'kimi-k3-highspeed', + created_at: '2026-09-03T16:40:00.000Z', + updated_at: '2026-09-03T17:00:06.700Z', + turn_count: 1, + }), + }); + harness.emit({ + type: 'session.meta.updated', + payload: { sessionId: 's_13', title: '修复登录页白屏', patch: { title: '修复登录页白屏' } }, + time: Date.parse('2026-09-03T17:00:06.800Z'), + }); + await settle(); + const expected = fixtureSection('session-changes', '自动标题(WS)'); + expect(harness.frames.length).toBe(1); + parseServerMessage(harness.frames[0]); + expect(harness.frames[0]).toEqual(expected[0]); + }); + + it('session-changes 设置变更', async () => { + const scenario = makeV2Scenario({ sessionId: 's_13' }); + const { buses, activity } = scenario; + const { socket } = scenario.connect(); + const drive = (event: FakeBusEvent) => buses.get('main')!.emit(event); + socket.deliver(JSON.stringify({ type: 'subscribe', id: 1, session_id: 's_13' })); + activity.set({ busy: false, mainTurnActive: false, pendingInteraction: 'none' }); + drive({ type: 'agent.activity.updated', lifecycle: 'ready', time: Date.parse('2026-09-03T17:00:00.000Z') }); + drive({ type: 'agent.status.updated', model: 'kimi-k3-highspeed', contextTokens: 5700, maxContextTokens: 262144, usage: { total: { inputOther: 5500, output: 162, inputCacheRead: 19500, inputCacheCreation: 0 } }, time: Date.parse('2026-09-03T17:00:00.000Z') }); + await settle(); + drive({ type: 'profile.bind', model: 'kimi-k3', time: Date.parse('2026-09-03T17:02:10.000Z') }); + await settle(); + drive({ type: 'permission.set_mode', mode: 'yolo', time: Date.parse('2026-09-03T17:02:40.000Z') }); + await settle(); + const expected = fixtureSection('session-changes', '设置变更(WS)'); + const states = socket.sent + .map((line) => JSON.parse(line) as Record) + .filter((frame) => frame['type'] === 'session.state' && (frame['timestamp'] as string) >= '2026-09-03T17:02:00.000Z'); + expect(states.length).toBe(2); + for (const frame of states) parseServerMessage(frame); + expect(states).toEqual(expected); + }); + + it('global 工作区', async () => { + const harness = makeFanoutHarness({ + workspaceWireFor: async (workspace) => { + const ws = workspace as { id: string; root: string; name: string; createdAt: number; lastOpenedAt: number; sessionCount: number }; + return { + id: ws.id, + root: ws.root, + name: ws.name, + created_at: new Date(ws.createdAt).toISOString(), + last_opened_at: new Date(ws.lastOpenedAt).toISOString(), + session_count: ws.sessionCount, + }; + }, + }); + harness.emit({ + type: 'event.workspace.created', + payload: { workspace: { id: 'ws_02', root: '/Users/moonshot/projects/demo', name: 'demo', createdAt: Date.parse('2026-09-03T17:10:00.000Z'), lastOpenedAt: Date.parse('2026-09-03T17:10:00.000Z'), sessionCount: 0 } }, + time: Date.parse('2026-09-03T17:10:00.010Z'), + }); + harness.emit({ + type: 'event.workspace.updated', + payload: { workspace: { id: 'ws_02', root: '/Users/moonshot/projects/demo', name: 'demo', createdAt: Date.parse('2026-09-03T17:10:00.000Z'), lastOpenedAt: Date.parse('2026-09-03T17:12:00.000Z'), sessionCount: 1 } }, + time: Date.parse('2026-09-03T17:12:00.010Z'), + }); + await settle(); + const expected = fixtureSection('global', '工作区(WS)'); + expect(harness.frames.length).toBe(2); + for (const frame of harness.frames) parseServerMessage(frame); + expect(harness.frames).toEqual(expected); + }); + + it('global 全局配置', async () => { + const harness = makeFanoutHarness({}); + harness.emit({ + type: 'event.config.changed', + payload: { + changedFields: ['theme'], + config: { + model: 'kimi-k3-highspeed', + theme: 'dark', + permission: 'manual', + max_turns: 100, + providers: { anthropic: { base_url: 'https://api.anthropic.com' } }, + }, + }, + time: Date.parse('2026-09-03T17:15:00.000Z'), + }); + await settle(); + const expected = fixtureSection('global', '全局配置(WS)'); + expect(harness.frames.length).toBe(1); + parseServerMessage(harness.frames[0]); + expect(harness.frames[0]).toEqual(expected[0]); + }); + + it('global 变更通知(通知型薄帧)', async () => { + const harness = makeFanoutHarness({}); + harness.emit({ type: 'event.model_catalog.changed', payload: { changed: [], unchanged: [], failed: [] }, time: Date.parse('2026-09-03T17:20:00.000Z') }); + harness.emit({ type: 'event.plugin.changed', payload: {}, time: Date.parse('2026-09-03T17:21:00.000Z') }); + harness.emit({ type: 'event.capability.changed', payload: { capability_id: 'mcp.github', install: { running: false } }, time: Date.parse('2026-09-03T17:22:00.000Z') }); + await settle(); + const expected = fixtureSection('global', '变更通知(WS)'); + expect(harness.frames.length).toBe(3); + for (const frame of harness.frames) parseServerMessage(frame); + expect(harness.frames).toEqual(expected); + }); + + it('全局扇出:未订阅连接同样收到全局帧', async () => { + const harness = makeFanoutHarness({}); + const scenario = makeV2Scenario({ sessionId: 's_15' }); + const { socket, frames } = scenario.connect({ globalFanout: harness.fanout }); + harness.emit({ type: 'event.model_catalog.changed', payload: {}, time: Date.parse('2026-09-03T17:20:00.000Z') }); + await settle(); + const got = frames(); + expect(got[0]).toEqual(HELLO_FRAME); + expect(got[1]).toEqual({ type: 'model_catalog', timestamp: '2026-09-03T17:20:00.000Z' }); + void socket; + }); + + it('system 剩余 subtype:hook / skill / notice / clear / swarm', () => { + const projector = new AgentV2Projector('s_x', 'main'); + const B = Date.parse('2026-09-03T18:00:00.000Z'); + const out = [ + ...projector.apply({ type: 'hook.result', hookEvent: 'user_prompt_submit', content: '继续', blocked: false, time: B + 10 }), + ...projector.apply({ type: 'skill.activated', skillName: 'review-pr', time: B + 20 }), + ...projector.apply({ type: 'plugin_command.activated', commandName: 'compact', time: B + 30 }), + ...projector.apply({ type: 'context.clear', time: B + 40 }), + ...projector.apply({ type: 'agent.status.updated', swarmMode: true, time: B + 50 }), + ...projector.apply({ type: 'agent.status.updated', swarmMode: false, time: B + 60 }), + ]; + for (const msg of out) parseServerMessage(msg); + const systems = out as { type: string; subtype: string; system_id: string }[]; + expect(systems.map((msg) => [msg.type, msg.subtype])).toEqual([ + ['system', 'hook'], + ['system', 'skill'], + ['system', 'notice'], + ['system', 'clear'], + ['system', 'swarm.enter'], + ['system', 'swarm.exit'], + ]); + expect(systems.map((msg) => msg.system_id)).toEqual(['m_01', 'm_02', 'm_03', 'm_04', 'm_05', 'm_06']); + expect(out[0]).toMatchObject({ payload: { event: 'user_prompt_submit', content: '继续' } }); + expect(out[1]).toMatchObject({ payload: { skill_name: 'review-pr' } }); + expect(out[2]).toMatchObject({ payload: { message: 'compact' } }); + }); +}); + +