873 lines
28 KiB
Markdown
873 lines
28 KiB
Markdown
# csc serve 事件标准化整改提案
|
||
|
||
> 状态:提案 (2026-05-18)
|
||
> 范围:`src/server/sessionMessageRouter.ts`、`src/server/eventBus.ts`、`src/server/sessionHandle.ts`
|
||
> 依赖:cs-cloud `internal/agent/csc/adapter*.go`(Phase 3 可移除)
|
||
|
||
---
|
||
|
||
## 1. 问题概述
|
||
|
||
### 1.1 现状
|
||
|
||
`csc serve` 采用子进程模式(`--print --output-format stream-json`),子进程的 stdout 消息由 `sessionMessageRouter.ts` 路由为 SSE 事件。当前路由只处理 7 种消息类型:
|
||
|
||
| 类型 | 处理 | SSE 事件 |
|
||
|---|---|---|
|
||
| `assistant` | handleAssistantMessage | `session.message` |
|
||
| `user` | handleUserMessage | `session.message` |
|
||
| `result` | handleResultMessage | `session.result` |
|
||
| `control_request` | handleControlRequest | `session.control_request` |
|
||
| `control_cancel_request` | handleCancelRequest | (opencode events) |
|
||
| `stream_event` | 直接转发 | `session.stream_event` |
|
||
| `system` | 统一发出 | `session.message`(不区分子类型) |
|
||
| **其余所有** | **default: break** | **丢弃** |
|
||
|
||
### 1.2 丢弃的消息类型
|
||
|
||
以下消息类型到达 router 后被静默丢弃或信息不完整:
|
||
|
||
| 消息类型 | TUI 行为 | serve 现状 | 影响 |
|
||
|---|---|---|---|
|
||
| `attachment` (50+ 子类型) | 专用组件渲染 | **丢弃** | Hook 结果、记忆、任务状态、诊断等全部不可见 |
|
||
| `progress` | 关联到工具消息显示 | **丢弃** | Bash 实时输出、Agent 进度不可见 |
|
||
| `system.task_notification` | `UserAgentNotificationMessage` 渲染 | 统一为 `session.message` | 消费端无法识别任务完成事件 |
|
||
| `system.task_started` | 任务面板追踪 | 统一为 `session.message` | 后台任务启动不可见 |
|
||
| `system.task_progress` | 进度条 | 统一为 `session.message` | 实时进度不可见 |
|
||
| `system.api_error` | 错误+重试倒计时 | 统一为 `session.message` | API 错误/重试信息不可用 |
|
||
| `system.compact_boundary` | 压缩边界指示器 | 统一为 `session.message` | 上下文压缩不可追踪 |
|
||
| `system.stop_hook_summary` | Hook 汇总 | 统一为 `session.message` | Hook 执行结果不可见 |
|
||
| `tombstone` | 移除孤立消息 | **不到达 router** | 压缩后孤立消息永久残留 |
|
||
| `stream_event` 内容 | TUI 细粒度处理 | raw Anthropic 格式转发 | 需要 cs-cloud Go adapter 重度适配 |
|
||
|
||
### 1.3 cs-cloud 适配层的代价
|
||
|
||
cs-cloud 的 CSC adapter 有 ~800 行 Go 代码专门做格式转换:
|
||
|
||
| 文件 | 行数 | 功能 |
|
||
|---|---|---|
|
||
| `adapter_sse_stream.go` | ~300 | Anthropic stream_event → message.part.delta |
|
||
| `adapter_sse_message.go` | ~350 | CSC 消息 → parts 分解 |
|
||
| `adapter_parts.go` | ~200 | 工具/文本 part 构建 |
|
||
| `adapter_json.go` | ~300 | REST 响应适配 |
|
||
|
||
且 adapter 仍有大量遗漏:所有 `system` 子类型被丢弃、`tombstone` 不处理、`attachment` 不处理、`tool_progress` 信息不完整。
|
||
|
||
### 1.4 根本原因
|
||
|
||
**csc serve 输出的是「子进程协议格式」,不是「消费端协议格式」**。TUI 内部通过 `handleMessageFromStream()` 做了一层转换(stream event → React state),但 serve 模式缺少这层转换,将原始格式直接暴露给外部消费者。
|
||
|
||
---
|
||
|
||
## 2. 目标
|
||
|
||
1. **csc serve 原生输出 opencode 兼容的 canonical 事件格式**,消除 cs-cloud adapter 的适配需求
|
||
2. **补齐 task 生命周期事件**:`task.started` → `task.progress` → `task.completed`
|
||
3. **补齐 system 子类型事件**:api_error、compact_boundary、hook_summary 等
|
||
4. **补齐 attachment/progress 消息路由**
|
||
5. **保持向后兼容**:新旧事件格式并存过渡期
|
||
|
||
---
|
||
|
||
## 3. Canonical 事件格式规范
|
||
|
||
### 3.1 SSE 事件命名规范
|
||
|
||
对标 opencode serve 的事件总线,csc serve 应输出以下事件:
|
||
|
||
| 事件名 | 替代现有 | 数据结构 |
|
||
|---|---|---|
|
||
| `session.created` | 保留 | `{ session_id, status, created_at }` |
|
||
| `session.updated` | 新增(替代部分 session.ready) | `{ session_id, status, model, provider_id }` |
|
||
| `session.deleted` | 保留 | `{ session_id }` |
|
||
| `session.status` | 保留 | `{ sessionID, status: { type: "busy"/"idle" } }` |
|
||
| `session.diff` | 新增 | `{ sessionID, diff: FileDiff[] }` |
|
||
| `session.error` | 新增 | `{ sessionID, error }` |
|
||
| `message.updated` | 新增(替代 session.message) | `{ sessionID, info: MessageInfo }` |
|
||
| `message.part.updated` | 新增(替代 stream_event 部分) | `{ sessionID, part: Part }` |
|
||
| `message.part.delta` | 新增(替代 stream_event 部分) | `{ sessionID, messageID, partID, field, delta }` |
|
||
| `permission.asked` | 已有(opencode 事件) | 保留 |
|
||
| `permission.replied` | 已有 | 保留 |
|
||
| `question.asked` | 已有 | 保留 |
|
||
| `question.replied` | 已有 | 保留 |
|
||
| `task.started` | 新增 | 见 §3.3 |
|
||
| `task.progress` | 新增 | 见 §3.3 |
|
||
| `task.completed` | 新增 | 见 §3.3 |
|
||
|
||
### 3.2 Part 类型规范
|
||
|
||
每个消息由一个或多个 Part 组成:
|
||
|
||
```typescript
|
||
type Part =
|
||
| { type: "text", id: string, text: string, time?: { start: number, end?: number } }
|
||
| { type: "reasoning", id: string, text: string, time?: { start: number, end?: number } }
|
||
| { type: "tool", id: string, callID: string, tool: string,
|
||
state: { status: "pending", input: Record<string,any> }
|
||
| { status: "running", input: Record<string,any>, title?: string, time: { start: number } }
|
||
| { status: "completed", input: Record<string,any>, output: string, title: string, time: { start: number, end: number } }
|
||
| { status: "error", input: Record<string,any>, error: string, time: { start: number, end: number } }
|
||
}
|
||
| { type: "step-start", id: string }
|
||
| { type: "step-finish", id: string, reason: string, cost: number, tokens: { input: number, output: number, reasoning: number, cache: { read: number, write: number } } }
|
||
| { type: "compaction", id: string, auto: boolean }
|
||
| { type: "subtask", id: string, prompt: string, description: string, agent: string }
|
||
```
|
||
|
||
### 3.3 Task 生命周期事件
|
||
|
||
```typescript
|
||
// task.started
|
||
{
|
||
sessionID: string,
|
||
taskID: string,
|
||
toolUseID?: string,
|
||
description: string,
|
||
taskType?: string, // "local_agent" | "local_shell" | "local_workflow"
|
||
workflowName?: string,
|
||
prompt?: string,
|
||
}
|
||
|
||
// task.progress
|
||
{
|
||
sessionID: string,
|
||
taskID: string,
|
||
description: string,
|
||
usage: { total_tokens: number, tool_uses: number, duration_ms: number },
|
||
lastToolName?: string,
|
||
summary?: string,
|
||
workflowProgress?: SdkWorkflowProgress[],
|
||
}
|
||
|
||
// task.completed
|
||
{
|
||
sessionID: string,
|
||
taskID: string,
|
||
toolUseID?: string,
|
||
status: "completed" | "failed" | "stopped",
|
||
summary: string,
|
||
outputFile: string,
|
||
usage?: { total_tokens: number, tool_uses: number, duration_ms: number },
|
||
}
|
||
```
|
||
|
||
### 3.4 System 子类型事件映射
|
||
|
||
| system.subtype | canonical 事件 | 备注 |
|
||
|---|---|---|
|
||
| `task_notification` | `task.completed` | 终态通知 |
|
||
| `task_started` | `task.started` | 启动事件 |
|
||
| `task_progress` | `task.progress` | 进度事件 |
|
||
| `api_error` / `api_retry` | `session.error` + `message.part.updated { type: "retry" }` | 重试信息 |
|
||
| `compact_boundary` | `message.part.updated { type: "compaction" }` | 压缩边界 |
|
||
| `stop_hook_summary` | `session.hook_summary` | Hook 汇总 |
|
||
| `turn_duration` | `session.metrics` | Turn 耗时 |
|
||
| `cache_warning` | `session.warning` | 缓存警告 |
|
||
| `informational` | `session.info` | 一般信息 |
|
||
|
||
---
|
||
|
||
## 4. 改造方案
|
||
|
||
### 4.1 Phase 1:sessionMessageRouter 增强(P0)
|
||
|
||
#### 4.1.1 新增 StreamStateTracker
|
||
|
||
**文件**:`src/server/streamStateTracker.ts`(新建)
|
||
|
||
追踪每个 session 的流式状态,将 Anthropic 原始 stream_event 分解为 canonical parts 事件:
|
||
|
||
```typescript
|
||
interface StreamState {
|
||
sessionID: string
|
||
messageID: string
|
||
parentID: string
|
||
modelID: string
|
||
activeBlocks: Map<number, {
|
||
type: string // "text" | "thinking" | "tool_use" | "redacted_thinking"
|
||
partID: string
|
||
toolUseID?: string
|
||
toolName?: string
|
||
inputJson: string // 累积的 tool input JSON
|
||
}>
|
||
stepPartID: string // step-start 的 part ID
|
||
usage: {
|
||
inputTokens: number
|
||
outputTokens: number
|
||
cacheReadTokens: number
|
||
cacheWriteTokens: number
|
||
}
|
||
stopReason: string
|
||
}
|
||
```
|
||
|
||
关键方法:
|
||
|
||
```typescript
|
||
class StreamStateTracker {
|
||
// 处理 Anthropic stream_event,返回 canonical 事件数组
|
||
processEvent(event: AnthropicStreamEvent, ctx: MessageRouterCtx): CanonicalEvent[]
|
||
}
|
||
```
|
||
|
||
处理逻辑:
|
||
|
||
| Anthropic 事件 | 输出 canonical 事件 |
|
||
|---|---|
|
||
| `message_start` | `message.updated` (assistant) + `message.part.updated { type: "step-start" }` |
|
||
| `content_block_start` (text) | `message.part.updated { type: "text" }` |
|
||
| `content_block_start` (thinking) | `message.part.updated { type: "reasoning" }` |
|
||
| `content_block_start` (tool_use) | `message.part.updated { type: "tool", status: "pending" }` |
|
||
| `content_block_start` (redacted_thinking) | `message.part.updated { type: "reasoning", redacted: true }` |
|
||
| `content_block_delta` (text_delta) | `message.part.delta { field: "text" }` |
|
||
| `content_block_delta` (thinking_delta) | `message.part.delta { field: "text" }` |
|
||
| `content_block_delta` (input_json_delta) | `message.part.delta { field: "input" }` |
|
||
| `content_block_stop` (tool_use) | `message.part.updated { type: "tool", status: "running" }` |
|
||
| `content_block_stop` (text/thinking) | _(finalize part timing)_ |
|
||
| `message_delta` | _(extract usage + stopReason, no event)_ |
|
||
| `message_stop` | `message.part.updated { type: "step-finish", cost, tokens, reason }` |
|
||
|
||
#### 4.1.2 改造 sessionMessageRouter.ts
|
||
|
||
**改动范围**:`src/server/sessionMessageRouter.ts`
|
||
|
||
##### a) stream_event case 改用 StreamStateTracker
|
||
|
||
```typescript
|
||
case 'stream_event': {
|
||
ctx.setLastActiveAt(Date.now())
|
||
const tracker = getOrCreateTracker(ctx.sessionId)
|
||
const canonicalEvents = tracker.processEvent(msg.event, ctx)
|
||
for (const event of canonicalEvents) {
|
||
ctx.emitOpencodeEvent(event.type, event.properties)
|
||
}
|
||
// 保留原始 stream_event 向后兼容(过渡期后移除)
|
||
ctx.emitEvent('stream_event', msg)
|
||
break
|
||
}
|
||
```
|
||
|
||
##### b) system case 增加子类型分发
|
||
|
||
```typescript
|
||
case 'system': {
|
||
const subtype = msg.subtype as string
|
||
switch (subtype) {
|
||
case 'task_notification':
|
||
emitTaskCompleted(msg, ctx)
|
||
break
|
||
case 'task_started':
|
||
emitTaskStarted(msg, ctx)
|
||
break
|
||
case 'task_progress':
|
||
emitTaskProgress(msg, ctx)
|
||
break
|
||
case 'api_error':
|
||
case 'api_retry':
|
||
emitSessionError(msg, ctx)
|
||
break
|
||
case 'compact_boundary':
|
||
case 'microcompact_boundary':
|
||
emitCompactionEvent(msg, ctx)
|
||
break
|
||
case 'stop_hook_summary':
|
||
emitHookSummary(msg, ctx)
|
||
break
|
||
default:
|
||
break
|
||
}
|
||
// 向后兼容:同时发 session.message
|
||
ctx.emitEvent('message', msg)
|
||
break
|
||
}
|
||
```
|
||
|
||
##### c) 新增 attachment case
|
||
|
||
```typescript
|
||
case 'attachment': {
|
||
ctx.emitOpencodeEvent('message.attachment', {
|
||
sessionID: ctx.sessionId,
|
||
attachmentType: (msg.attachment as Record<string, unknown>)?.type,
|
||
attachment: msg.attachment,
|
||
})
|
||
// 不发 session.message(attachment 不是对话消息)
|
||
break
|
||
}
|
||
```
|
||
|
||
##### d) 新增 progress case
|
||
|
||
```typescript
|
||
case 'progress': {
|
||
const toolUseID = msg.toolUseID as string
|
||
ctx.emitOpencodeEvent('tool.progress', {
|
||
sessionID: ctx.sessionId,
|
||
toolUseID,
|
||
parentToolUseID: msg.parentToolUseID,
|
||
data: msg.data,
|
||
})
|
||
break
|
||
}
|
||
```
|
||
|
||
##### e) handleResultMessage 增强
|
||
|
||
```typescript
|
||
function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
// ... 现有逻辑 ...
|
||
|
||
const stopReason = msg.stop_reason as string | undefined
|
||
const modelUsage = msg.modelUsage as Record<string, unknown> | undefined
|
||
|
||
ctx.emitOpencodeEvent('session.status', {
|
||
sessionID: ctx.sessionId,
|
||
status: { type: 'idle' },
|
||
})
|
||
|
||
// 如果有 diff 信息,发射 session.diff
|
||
// 如果有错误子类型,发射 session.error
|
||
if (msg.subtype !== 'success') {
|
||
ctx.emitOpencodeEvent('session.error', {
|
||
sessionID: ctx.sessionId,
|
||
error: {
|
||
subtype: msg.subtype,
|
||
is_error: msg.is_error,
|
||
errors: msg.errors,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 4.1.3 新增 tombstone 传递
|
||
|
||
**文件**:`src/QueryEngine.ts`
|
||
|
||
当前 tombstone 在 QueryEngine 中被拦截不转发。需要在 serve 模式下将其转发到 stdout:
|
||
|
||
```typescript
|
||
// QueryEngine.ts — yield 逻辑中
|
||
if (message.type === 'tombstone') {
|
||
// 现有:skipped
|
||
// 新增:在 serve 模式下转发
|
||
if (getIsNonInteractiveSession()) {
|
||
yield message as StdoutMessage
|
||
}
|
||
continue
|
||
}
|
||
```
|
||
|
||
**sessionMessageRouter.ts** 新增 case:
|
||
|
||
```typescript
|
||
case 'tombstone': {
|
||
const targetUuid = (msg.message as { uuid: string })?.uuid
|
||
if (targetUuid) {
|
||
ctx.emitOpencodeEvent('message.removed', {
|
||
sessionID: ctx.sessionId,
|
||
messageID: targetUuid,
|
||
})
|
||
}
|
||
break
|
||
}
|
||
```
|
||
|
||
### 4.2 Phase 2:assistant 消息 parts 分解(P1)
|
||
|
||
当收到完整的 `assistant` 消息(非流式,如历史加载),需要在 router 层将其 content blocks 分解为 parts:
|
||
|
||
```typescript
|
||
function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
// ... 现有逻辑 ...
|
||
|
||
// 新增:发射 message.updated 事件
|
||
ctx.emitOpencodeEvent('message.updated', {
|
||
sessionID: ctx.sessionId,
|
||
info: {
|
||
id: msg.uuid,
|
||
role: 'assistant',
|
||
modelID: msg.model,
|
||
providerID: msg.provider_id,
|
||
cost: 0, // 单条消息无独立 cost
|
||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||
time: { created: msg.timestamp ? new Date(msg.timestamp as string).getTime() : Date.now() },
|
||
parentID: msg.parentUuid ?? null,
|
||
},
|
||
})
|
||
|
||
// 新增:分解 content blocks 为 parts
|
||
const content = msg.message?.content as Array<Record<string, unknown>> ?? []
|
||
for (const block of content) {
|
||
const part = buildPartFromContentBlock(block, msg)
|
||
if (part) {
|
||
ctx.emitOpencodeEvent('message.part.updated', {
|
||
sessionID: ctx.sessionId,
|
||
part,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildPartFromContentBlock(block: Record<string, unknown>, msg: StdoutMessage): Part | null {
|
||
switch (block.type) {
|
||
case 'text':
|
||
return { type: 'text', id: uuid(), text: block.text as string }
|
||
case 'thinking':
|
||
return { type: 'reasoning', id: uuid(), text: block.thinking as string }
|
||
case 'redacted_thinking':
|
||
return { type: 'reasoning', id: uuid(), text: '', redacted: true }
|
||
case 'tool_use':
|
||
return {
|
||
type: 'tool', id: uuid(),
|
||
callID: block.id as string,
|
||
tool: normalizeToolName(block.name as string),
|
||
state: {
|
||
status: 'running',
|
||
input: block.input as Record<string, unknown>,
|
||
time: { start: Date.now() },
|
||
},
|
||
}
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 Phase 3:向后兼容过渡(P1)
|
||
|
||
#### 4.3.1 双写期
|
||
|
||
在 Phase 1/2 实施后,新旧事件同时发出:
|
||
|
||
```
|
||
session.message (旧) → 保留
|
||
message.updated (新) → 新增
|
||
session.stream_event (旧) → 保留
|
||
message.part.delta (新) → 新增
|
||
session.control_request (旧) → 保留
|
||
permission.asked (新) → 已有
|
||
```
|
||
|
||
#### 4.3.2 cs-cloud 逐步切换
|
||
|
||
cs-cloud adapter 逐步切换到新事件:
|
||
|
||
1. **v1**:adapter 优先消费新事件,旧事件作为 fallback
|
||
2. **v2**:adapter 只消费新事件,移除 `adapter_sse_stream.go`、`adapter_sse_message.go`、`adapter_parts.go`
|
||
3. **v3**:csc serve 移除旧事件输出,cs-cloud adapter 变为 thin proxy
|
||
|
||
#### 4.3.3 版本协商(可选)
|
||
|
||
SSE 连接可通过 `Accept-Event-Version: 2` header 选择新格式:
|
||
|
||
```typescript
|
||
// event route handler
|
||
const eventVersion = c.req.header('accept-event-version')
|
||
if (eventVersion === '2') {
|
||
// 只发 canonical 事件
|
||
} else {
|
||
// 双写
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 新增事件处理函数
|
||
|
||
### 5.1 Task 生命周期
|
||
|
||
```typescript
|
||
// sessionMessageRouter.ts — 新增函数
|
||
|
||
function emitTaskStarted(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
ctx.emitOpencodeEvent('task.started', {
|
||
sessionID: ctx.sessionId,
|
||
taskID: msg.task_id as string,
|
||
toolUseID: msg.tool_use_id as string | undefined,
|
||
description: msg.description as string,
|
||
taskType: msg.task_type as string | undefined,
|
||
workflowName: msg.workflow_name as string | undefined,
|
||
prompt: msg.prompt as string | undefined,
|
||
})
|
||
}
|
||
|
||
function emitTaskProgress(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
ctx.emitOpencodeEvent('task.progress', {
|
||
sessionID: ctx.sessionId,
|
||
taskID: msg.task_id as string,
|
||
description: msg.description as string,
|
||
usage: msg.usage,
|
||
lastToolName: msg.last_tool_name as string | undefined,
|
||
summary: msg.summary as string | undefined,
|
||
workflowProgress: msg.workflow_progress,
|
||
})
|
||
}
|
||
|
||
function emitTaskCompleted(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
ctx.emitOpencodeEvent('task.completed', {
|
||
sessionID: ctx.sessionId,
|
||
taskID: msg.task_id as string,
|
||
toolUseID: msg.tool_use_id as string | undefined,
|
||
status: msg.status as 'completed' | 'failed' | 'stopped',
|
||
summary: msg.summary as string,
|
||
outputFile: msg.output_file as string,
|
||
usage: msg.usage,
|
||
})
|
||
}
|
||
```
|
||
|
||
### 5.2 System 子类型
|
||
|
||
```typescript
|
||
function emitSessionError(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
ctx.emitOpencodeEvent('session.error', {
|
||
sessionID: ctx.sessionId,
|
||
error: {
|
||
subtype: msg.subtype,
|
||
level: msg.level ?? 'error',
|
||
message: msg.content ?? msg.error?.message,
|
||
retryInMs: msg.retry_in_ms ?? msg.retryInMs,
|
||
retryAttempt: msg.retry_attempt ?? msg.retryAttempt,
|
||
maxRetries: msg.max_retries ?? msg.maxRetries,
|
||
},
|
||
})
|
||
}
|
||
|
||
function emitCompactionEvent(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
ctx.emitOpencodeEvent('message.part.updated', {
|
||
sessionID: ctx.sessionId,
|
||
part: {
|
||
type: 'compaction',
|
||
id: msg.uuid ?? randomUUID(),
|
||
auto: msg.subtype === 'microcompact_boundary' || (msg.compact_metadata as any)?.trigger === 'auto',
|
||
},
|
||
})
|
||
}
|
||
|
||
function emitHookSummary(msg: StdoutMessage, ctx: MessageRouterCtx): void {
|
||
ctx.emitOpencodeEvent('session.hook_summary', {
|
||
sessionID: ctx.sessionId,
|
||
hookLabel: msg.hook_label ?? msg.hookLabel,
|
||
hookCount: msg.hook_count ?? msg.hookCount,
|
||
hookErrors: msg.hook_errors ?? msg.hookErrors,
|
||
preventedContinuation: msg.prevented_continuation ?? msg.preventedContinuation,
|
||
totalDurationMs: msg.total_duration_ms ?? msg.totalDurationMs,
|
||
})
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. StreamStateTracker 详细设计
|
||
|
||
### 6.1 状态管理
|
||
|
||
```typescript
|
||
// src/server/streamStateTracker.ts
|
||
|
||
import { randomUUID } from 'crypto'
|
||
|
||
interface BlockState {
|
||
type: string
|
||
partID: string
|
||
toolUseID?: string
|
||
toolName?: string
|
||
inputJson: string
|
||
startTime: number
|
||
}
|
||
|
||
interface SessionStreamState {
|
||
messageID: string
|
||
parentID: string
|
||
modelID: string
|
||
activeBlocks: Map<number, BlockState>
|
||
stepStartPartID: string
|
||
assistantPartEmitted: boolean
|
||
usage: {
|
||
inputTokens: number
|
||
outputTokens: number
|
||
cacheReadTokens: number
|
||
cacheWriteTokens: number
|
||
}
|
||
stopReason: string
|
||
}
|
||
|
||
const states = new Map<string, SessionStreamState>()
|
||
|
||
function getOrCreate(sessionID: string): SessionStreamState {
|
||
let state = states.get(sessionID)
|
||
if (!state) {
|
||
state = {
|
||
messageID: randomUUID(),
|
||
parentID: '',
|
||
modelID: '',
|
||
activeBlocks: new Map(),
|
||
stepStartPartID: randomUUID(),
|
||
assistantPartEmitted: false,
|
||
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
|
||
stopReason: '',
|
||
}
|
||
states.set(sessionID, state)
|
||
}
|
||
return state
|
||
}
|
||
|
||
function reset(sessionID: string): void {
|
||
states.delete(sessionID)
|
||
}
|
||
```
|
||
|
||
### 6.2 事件处理
|
||
|
||
```typescript
|
||
type CanonicalEvent = {
|
||
type: string
|
||
properties: Record<string, unknown>
|
||
}
|
||
|
||
export function processStreamEvent(
|
||
sessionID: string,
|
||
event: Record<string, unknown>,
|
||
): CanonicalEvent[] {
|
||
const results: CanonicalEvent[] = []
|
||
const state = getOrCreate(sessionID)
|
||
const eventType = event.type as string
|
||
|
||
switch (eventType) {
|
||
case 'message_start': {
|
||
const msg = (event as any).message
|
||
state.messageID = randomUUID()
|
||
state.modelID = msg?.model ?? ''
|
||
state.usage.inputTokens = msg?.usage?.input_tokens ?? 0
|
||
state.usage.cacheReadTokens = msg?.usage?.cache_read_input_tokens ?? 0
|
||
state.usage.cacheWriteTokens = msg?.usage?.cache_creation_input_tokens ?? 0
|
||
|
||
if (!state.assistantPartEmitted) {
|
||
results.push({
|
||
type: 'message.updated',
|
||
properties: {
|
||
sessionID,
|
||
info: {
|
||
id: state.messageID,
|
||
role: 'assistant',
|
||
modelID: state.modelID,
|
||
time: { created: Date.now() },
|
||
},
|
||
},
|
||
})
|
||
state.assistantPartEmitted = true
|
||
}
|
||
|
||
results.push({
|
||
type: 'message.part.updated',
|
||
properties: {
|
||
sessionID,
|
||
part: { type: 'step-start', id: state.stepStartPartID, sessionID, messageID: state.messageID },
|
||
},
|
||
})
|
||
break
|
||
}
|
||
|
||
case 'content_block_start': {
|
||
const block = (event as any).content_block
|
||
const index = (event as any).index as number
|
||
const partID = randomUUID()
|
||
const blockState: BlockState = {
|
||
type: block.type,
|
||
partID,
|
||
inputJson: '',
|
||
startTime: Date.now(),
|
||
}
|
||
|
||
state.activeBlocks.set(index, blockState)
|
||
|
||
if (block.type === 'text') {
|
||
results.push({
|
||
type: 'message.part.updated',
|
||
properties: {
|
||
sessionID,
|
||
part: { type: 'text', id: partID, sessionID, messageID: state.messageID, text: '' },
|
||
},
|
||
})
|
||
} else if (block.type === 'thinking' || block.type === 'redacted_thinking') {
|
||
results.push({
|
||
type: 'message.part.updated',
|
||
properties: {
|
||
sessionID,
|
||
part: {
|
||
type: 'reasoning', id: partID, sessionID, messageID: state.messageID,
|
||
text: '', redacted: block.type === 'redacted_thinking',
|
||
},
|
||
},
|
||
})
|
||
} else if (block.type === 'tool_use') {
|
||
blockState.toolUseID = block.id
|
||
blockState.toolName = block.name
|
||
results.push({
|
||
type: 'message.part.updated',
|
||
properties: {
|
||
sessionID,
|
||
part: {
|
||
type: 'tool', id: partID, sessionID, messageID: state.messageID,
|
||
callID: block.id, tool: block.name,
|
||
state: { status: 'pending', input: {}, raw: '' },
|
||
},
|
||
},
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'content_block_delta': {
|
||
const index = (event as any).index as number
|
||
const delta = (event as any).delta
|
||
const blockState = state.activeBlocks.get(index)
|
||
|
||
if (!blockState) break
|
||
|
||
if (delta.type === 'text_delta') {
|
||
results.push({
|
||
type: 'message.part.delta',
|
||
properties: { sessionID, messageID: state.messageID, partID: blockState.partID, field: 'text', delta: delta.text },
|
||
})
|
||
} else if (delta.type === 'thinking_delta') {
|
||
results.push({
|
||
type: 'message.part.delta',
|
||
properties: { sessionID, messageID: state.messageID, partID: blockState.partID, field: 'text', delta: delta.thinking },
|
||
})
|
||
} else if (delta.type === 'input_json_delta') {
|
||
blockState.inputJson += delta.partial_json
|
||
results.push({
|
||
type: 'message.part.delta',
|
||
properties: { sessionID, messageID: state.messageID, partID: blockState.partID, field: 'input', delta: delta.partial_json },
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'content_block_stop': {
|
||
const index = (event as any).index as number
|
||
const blockState = state.activeBlocks.get(index)
|
||
if (!blockState) break
|
||
|
||
if (blockState.type === 'tool_use') {
|
||
let parsedInput: Record<string, unknown> = {}
|
||
try { parsedInput = JSON.parse(blockState.inputJson || '{}') } catch {}
|
||
results.push({
|
||
type: 'message.part.updated',
|
||
properties: {
|
||
sessionID,
|
||
part: {
|
||
type: 'tool', id: blockState.partID, sessionID, messageID: state.messageID,
|
||
callID: blockState.toolUseID, tool: blockState.toolName,
|
||
state: { status: 'running', input: parsedInput, time: { start: blockState.startTime } },
|
||
},
|
||
},
|
||
})
|
||
}
|
||
break
|
||
}
|
||
|
||
case 'message_delta': {
|
||
const delta = (event as any).delta
|
||
const usage = (event as any).usage
|
||
if (delta?.stop_reason) state.stopReason = delta.stop_reason
|
||
if (usage?.output_tokens) state.usage.outputTokens = usage.output_tokens
|
||
break
|
||
}
|
||
|
||
case 'message_stop': {
|
||
results.push({
|
||
type: 'message.part.updated',
|
||
properties: {
|
||
sessionID,
|
||
part: {
|
||
type: 'step-finish', id: randomUUID(), sessionID, messageID: state.messageID,
|
||
reason: state.stopReason || 'stop',
|
||
cost: 0,
|
||
tokens: {
|
||
input: state.usage.inputTokens,
|
||
output: state.usage.outputTokens,
|
||
reasoning: 0,
|
||
cache: { read: state.usage.cacheReadTokens, write: state.usage.cacheWriteTokens },
|
||
},
|
||
},
|
||
},
|
||
})
|
||
reset(sessionID)
|
||
break
|
||
}
|
||
}
|
||
|
||
return results
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. 测试计划
|
||
|
||
### 7.1 单元测试
|
||
|
||
| 测试文件 | 覆盖范围 |
|
||
|---|---|
|
||
| `src/server/__tests__/streamStateTracker.test.ts` | 所有 Anthropic stream event → canonical event 转换 |
|
||
| `src/server/__tests__/sessionMessageRouter.test.ts` | system 子类型分发、attachment/progress/tombstone 路由 |
|
||
|
||
### 7.2 集成测试
|
||
|
||
- 启动 csc serve,发送 prompt,验证 SSE 事件流包含:
|
||
- `message.updated` + `message.part.updated` (text/reasoning/tool parts)
|
||
- `message.part.delta` (流式 delta)
|
||
- `task.started` / `task.completed`(后台 agent 场景)
|
||
- `session.error`(API 错误场景)
|
||
- `message.part.updated { type: "compaction" }`(压缩场景)
|
||
|
||
### 7.3 兼容性测试
|
||
|
||
- cs-cloud adapter 在双写期仍能正常工作(消费旧事件)
|
||
- cs-cloud adapter 切换到新事件后功能不降级
|
||
|
||
---
|
||
|
||
## 8. 实施计划
|
||
|
||
| Phase | 内容 | 预估工时 | 依赖 |
|
||
|---|---|---|---|
|
||
| Phase 1a | StreamStateTracker 实现 + stream_event 分解 | 3-4 天 | 无 |
|
||
| Phase 1b | system 子类型分发(task_*、api_error、compact_boundary) | 2 天 | 无 |
|
||
| Phase 1c | attachment / progress / tombstone 路由 | 1-2 天 | 无 |
|
||
| Phase 1d | handleResultMessage 增强 | 0.5 天 | 无 |
|
||
| Phase 2 | assistant 消息 parts 分解(历史加载场景) | 2 天 | Phase 1a |
|
||
| Phase 3a | cs-cloud adapter 优先消费新事件 | 3-4 天 | Phase 1+2 |
|
||
| Phase 3b | 移除 cs-cloud adapter 重度适配代码 | 1 天 | Phase 3a |
|
||
| Phase 3c | csc serve 移除旧事件(下个 major 版本) | 0.5 天 | Phase 3b |
|
||
|
||
---
|
||
|
||
## 9. 风险与缓解
|
||
|
||
| 风险 | 缓解措施 |
|
||
|---|---|
|
||
| 双写期 SSE 事件量翻倍影响性能 | StreamStateTracker 产出的事件替代而非追加;旧事件可在 Phase 3 移除 |
|
||
| StreamStateTracker 状态泄漏 | message_stop 后 reset;session 关闭时清理 |
|
||
| 子进程协议变更影响非 serve 消费者 | 只改 sessionMessageRouter(serve 专属),不改子进程 stdout 协议 |
|
||
| tool input JSON 解析失败 | try/catch 包裹,fallback 为 `{}` |
|
||
| 多 step(tool_use 循环)中 messageID 管理错误 | 每次 message_start 生成新 messageID,step-finis/step-start 配对 |
|
||
|
||
---
|
||
|
||
## 10. 参考
|
||
|
||
- `src/server/sessionMessageRouter.ts` — 当前消息路由
|
||
- `src/server/eventBus.ts` — SSE 事件总线
|
||
- `src/server/sessionHandle.ts` — 子进程管理
|
||
- `src/utils/handleMessageFromStream()` — TUI 的流式事件处理(`src/utils/messages.ts:3262`)
|
||
- `packages/@ant/model-provider/src/types/message.ts` — 消息类型定义
|
||
- `src/utils/sdkEventQueue.ts` — SDK 事件队列(task_started/task_progress/task_notification)
|
||
- cs-cloud `internal/agent/csc/adapter_sse_stream.go` — Go 层流式适配(对标参考)
|
||
- cs-cloud `internal/agent/csc/adapter_sse_message.go` — Go 层消息适配(对标参考)
|
||
- opencode `packages/opencode/src/session/message-v2.ts` — canonical Part 类型定义
|
||
- opencode `packages/opencode/src/session/processor.ts` — 流式事件处理参考
|