Compare commits

...
Author SHA1 Message Date
CoderLambert 675da1af96 docs: deepen codex runtime streaming notes from source 2026-09-11 21:03:37 +08:00
CoderLambert 294ecae6f2 docs: integrate professional knowledge notes
* docs: add professional notes index

* docs: add end-to-end review note

* docs: classify legacy architecture summary

* docs: classify integration notes

* docs: classify development plan

* docs: classify validation snapshot

* docs: classify task documents
2026-09-11 20:23:16 +08:00
8 changed files with 1968 additions and 0 deletions
+2
View File
@@ -1,5 +1,7 @@
# Architecture
> **文档定位:V0 架构摘要 / 项目记录。** 本文保留用于快速查看当前架构边界,不承担系统教学职责。完整的架构心智模型、设计理由、替代方案、失败模式与复习内容请阅读 [`docs/notes/01-system-architecture.md`](./notes/01-system-architecture.md);端到端串联请阅读 [`docs/notes/05-end-to-end-review.md`](./notes/05-end-to-end-review.md)。
## Boundary
The Codex app-server is server-only. Browser code must never import the SDK/protocol client or read local Codex authentication files.
+2
View File
@@ -1,5 +1,7 @@
# Five-task development plan
> **文档定位:开发拆分与协作记录。** 本文描述当时如何把实现拆成五个任务,不是技术知识笔记。长期学习请从 [`docs/notes/README.md`](./notes/README.md) 开始;该索引按“架构 → Runtime → Web 状态 → 可靠性 → 端到端”组织。
The repository is intentionally split into four parallel implementation tracks plus one integration track. Parallel tasks should minimize overlapping file ownership.
| Task | Scope | Primary file ownership | Deliverable |
+2
View File
@@ -1,5 +1,7 @@
# Task 05 integration notes
> **文档定位:集成实施记录。** 本文记录 Task 05 当时如何收敛模块边界,不作为长期学习入口。关于 Runtime/App Server 的系统知识请阅读 [`docs/notes/02-codex-app-server-streaming.md`](./notes/02-codex-app-server-streaming.md);关于 TanStack/React 状态模型请阅读 [`docs/notes/03-tanstack-streaming-state.md`](./notes/03-tanstack-streaming-state.md);完整链路请阅读 [`docs/notes/05-end-to-end-review.md`](./notes/05-end-to-end-review.md)。
## Resolved module boundaries
### Task 01 -> Task 02
+2
View File
@@ -1,5 +1,7 @@
# Validation report
> **文档定位:某次集成验证快照。** 这里记录当时实际跑过的检查与修复,不应被视为当前所有可靠性结论。关于“测试通过仍不能证明什么”、fake app-server、correlation、interrupt、process cleanup 与安全边界,请阅读 [`docs/notes/04-testing-reliability-security.md`](./notes/04-testing-reliability-security.md)。
Task 05 integration was performed against the common base and the Task 01-04 outputs were reconciled into one transport/state/UI pipeline.
## Final checks
@@ -0,0 +1,911 @@
# Codex App Server Streaming:源码驱动的 Runtime 深挖
> 基线:`main@294ecae6`
>
> 本文围绕 `src/server/codex/codex-app-server.server.ts` 的真实实现,重点理解:child process、stdio 协议、request/response correlation、notification、AsyncGenerator、AbortSignal、资源释放和供应商协议防腐层。统一模板:**源码位置 → 为什么需要 → 定义 → 当前实现 → 生产风险 → 工业级范式 → 复习检查**。
---
## 1. Runtime 的真正职责:把“进程协议”变成“应用事件流”
当前 Runtime 做的事情可以压缩为:
```text
spawn codex app-server
initialize handshake
thread/start | thread/resume
turn/start
consume stdout messages
map provider event
yield CodexThreadEvent
cleanup child process
```
因此 Runtime 不是“调用模型 API 的函数”,而是一个**协议客户端 + 生命周期管理器 + 数据翻译器**。
对应源码:
```text
src/server/codex/codex-app-server.server.ts
src/server/codex/codex-runtime.ts
src/server/codex/codex.errors.ts
src/server/codex/thread-selection.server.ts
src/server/codex/workspace.server.ts
```
---
## 2. `spawn()`:为什么 Agent Runtime 更像进程监督而不是普通 HTTP 请求
源码:
```ts
this.child = spawn(codexPath, ['app-server', '--stdio'], {
cwd: process.cwd(),
stdio: 'pipe',
})
```
### 定义
Node `child_process.spawn()` 启动一个独立 OS 进程,并暴露:
```text
stdin -> host 写给 child
stdout -> child 写给 host
stderr -> 诊断输出
exit -> 生命周期信号
```
`exec()` 相比,`spawn()` 更适合长生命周期和流式协议,因为它不会先把整个输出缓存在内存里再返回。
### 为什么当前场景需要它
Codex App Server 是持续交互协议:
```text
host request
server response
server notification
host request
server notification
...
```
如果使用一次性命令执行模型:
```text
command -> wait -> whole output
```
就无法自然表达一个 turn 中持续出现的 delta、tool event 和 usage。
### 生产风险
child process 不是普通 Promise。必须处理:
- executable 不存在;
- 启动权限失败;
- stdout 提前关闭;
- stderr 持续增长;
- child 卡死;
- host 请求取消;
- parent 退出后 orphan process
- kill 后进程没有及时退出。
### 工业级范式
把进程抽象成明确状态机:
```text
CREATED
↓ spawn
STARTING
↓ initialize
READY
↓ turn/start
RUNNING
↓ complete / fail / abort
CLOSING
↓ exit
CLOSED
```
生产实现最好对每个状态定义:
```text
允许的操作
超时
退出条件
日志字段
失败错误码
```
---
## 3. `readline` + AsyncIterator:为什么 stdout 是“消息流”而不是字符串
源码:
```ts
this.lines = createInterface({ input: this.child.stdout })
this.messages = this.lines[Symbol.asyncIterator]()
```
App Server 通过 newline-delimited JSON 风格的数据交换消息,因此 stdout 的正确抽象不是:
```text
一个大字符串
```
而是:
```text
message 1
message 2
message 3
...
```
### `Symbol.asyncIterator` 的意义
可以写出:
```ts
const next = await this.messages.next()
```
或者更高层:
```ts
for await (const line of lines) {
// consume one message at a time
}
```
这使协议解析与 Node stream 的 chunk 边界解耦。TCP/pipe chunk 并不保证一次 data event 就对应一个完整 JSON message;按“行”解析才符合协议 framing。
### 生产避坑
永远不要写:
```ts
child.stdout.on('data', chunk => JSON.parse(chunk))
```
原因:
```text
一个 chunk 可能只有半条 JSON
一个 chunk 也可能包含多条 JSON
```
消息协议必须先处理 framing,再处理 parsing。
---
## 4. JSON-RPC correlation:为什么“发请求后读下一条消息”是错误模型
当前 `request()`
```ts
const id = this.send(method, params)
const notifications: AppServerMessage[] = []
while (true) {
const message = await this.nextMessage()
if (message.id === id) {
return { response: message, notifications }
}
if (this.isServerRequest(message)) {
this.rejectServerRequest(message)
} else if (message.method) {
notifications.push(message)
}
}
```
### 核心定义
RPC correlation 的本质:
```text
request id -> matching response
```
而不是:
```text
request -> next line is response
```
因为 response 到达前可能插入:
- notification
- server request
- 其它并发 request 的 response。
### 当前实现的能力边界
当前连接实现更适合:
```text
同一时刻一个 request() 顺序等待
```
而不是完整并发 dispatcher。
如果未来一个连接同时发:
```text
turn/start
thread/read
turn/interrupt
```
多个 `request()` 不能各自独立消费同一个 async iterator,否则会争抢消息。
### 工业级并发范式
应该升级成单一 reader loop
```text
stdout
one dispatcher
├─ response id -> pendingRequests Map
├─ notification -> event channel
└─ server request -> request handler
```
伪代码:
```ts
const pending = new Map<RequestId, Deferred>()
for await (const message of source) {
if (isResponse(message)) {
pending.get(message.id)?.resolve(message)
} else if (isNotification(message)) {
publish(message)
} else if (isServerRequest(message)) {
handleServerRequest(message)
}
}
```
这才支持真正的 multiplexing。
---
## 5. Request / Response / Notification / Server Request:四类消息必须严格区分
协议心智模型:
| 类型 | id | method | 谁发起 | 宿主动作 |
|---|---|---|---|---|
| Request | 有 | 有 | Host | 等 response |
| Response | 有 | 无 | Server | resolve pending request |
| Notification | 无 | 有 | Server | 转成事件 |
| Server Request | 有 | 有 | Server | 必须回答 |
当前代码识别 Server Request
```ts
return (
message.id !== undefined &&
message.method !== undefined &&
message.result === undefined &&
message.error === undefined
)
```
V0 统一拒绝:
```ts
error: {
code: -32000,
message: 'Interactive server requests are disabled.',
}
```
### 为什么这是安全决策
因为 `approvalPolicy: never` 的 V0 不应该让 Runtime 在执行中临时向 Browser 请求权限升级。
### 未来怎么演进
不要直接改成“弹窗点允许”。应该引入:
```text
Server Request
↓ policy engine
capability + workspace + user policy
approve / deny / require explicit user confirmation
audit log
```
审批是安全协议,不是 UI 交互细节。
---
## 6. initialize / initialized:握手其实是连接状态机
源码:
```ts
await connection.request('initialize', {
clientInfo: {...},
capabilities: {
experimentalApi: true,
requestAttestation: false,
},
})
connection.notify('initialized')
```
### 为什么不能跳过
握手用来确认:
- 客户端身份;
- 协议能力;
- 实验 API 支持;
- attestation 等交互能力。
这是一种 capability negotiation。
### 工业级范式
不要在业务逻辑里到处硬编码 capability JSON。长期应抽成:
```ts
interface RuntimeCapabilities {
experimentalApi: boolean
requestAttestation: boolean
approvals: 'deny' | 'interactive'
}
```
并让初始化结果形成 ConnectionContext。
---
## 7. Thread resume:为什么浏览器给的 threadId 仍然不能直接信任
Runtime
```ts
const threadId = normalizeThreadId(input.threadId)
const threadRequest = threadId ? 'thread/resume' : 'thread/start'
```
### 原则
来自 Browser 的任何标识符都属于外部输入:
```text
类型正确 ≠ 语义可信
```
即使 TypeScript 类型是 `string`,运行时仍可能收到:
- 空字符串;
- 非法格式;
- 超长值;
- stale id
- 恶意构造值。
### 工业级范式
边界数据总是:
```text
unknown
↓ validate / normalize
trusted internal type
```
不要因为前后端共用 TypeScript 类型就省略运行时验证。
---
## 8. `mapAppServerItem()`:供应商对象为什么必须在 Runtime 内收敛
源码把 App Server item
```text
agentMessage
reasoning
commandExecution
fileChange
mcpToolCall
webSearch
error
```
映射成内部 `CodexThreadItem`
特别值得注意的是 MCP
```ts
arguments: undefined,
result: undefined,
error: undefined,
```
这不是“数据没拿到”,而是数据最小化策略。
### 为什么
MCP arguments/result 可能包含:
- 文件正文;
- token
- 用户数据;
- 系统路径;
- 第三方服务响应。
Runtime 层是最适合做第一轮脱敏的地方。
### 工业级范式
Mapping function 应被视为 security boundary
```text
Provider Object
↓ allow-list mapper
Internal Runtime Event
```
优先白名单,不使用:
```ts
return { ...providerObject }
```
因为外部协议新增字段时,spread 会让新字段自动穿透安全边界。
---
## 9. Delta + Completed Snapshot:体验与最终事实必须分开
事件:
```text
item/agentMessage/delta × N
item/completed(agentMessage)
```
正确理解:
```text
delta = incremental transport
completed = authoritative snapshot
```
### 为什么 completed 不能省
实时 delta 可能因为:
- transport 丢失;
- reconnect
- adapter bug
- provider 修订;
- chunk 重复;
导致客户端拼出来的文本不完全可靠。
最终 snapshot 可以做 reconciliation
```text
实时:append delta
结束:replace with final snapshot
```
这和数据库中的:
```text
optimistic projection + authoritative commit
```
非常相似。
---
## 10. Usage 是流式状态,不应假设只在结束时出现
源码:
```ts
case 'thread/tokenUsage/updated': {
const last = ...
if (last) usage.value = last
return []
}
```
最后:
```ts
return [{ type: 'turn.completed', usage: usage.value }]
```
### 设计含义
Runtime 内维护一个最新 usage snapshot
```text
usage notification × N
latest usage state
turn.completed
```
这说明协议中的“统计信息”也可能是流,而不是单个最终 response 字段。
### 生产演进
如果需要实时成本 UI,可以将 usage 变成显式 application event;如果产品暂时不需要,就保持 Runtime 内部收敛,避免无意义事件放大。
---
## 11. `AbortSignal`:取消必须从 UI 一直传播到资源层
源码:
```ts
if (input.signal?.aborted) {
throw new Error('Codex turn was cancelled.')
}
const abortHandler = () => connection.close().catch(() => undefined)
input.signal?.addEventListener('abort', abortHandler, { once: true })
```
### 定义
AbortSignal 是 cooperative cancellation
```text
caller 发出取消意图
callee 监听 signal
callee 主动停止工作并释放资源
```
它不是线程强杀机制。
### 为什么 generation guard 不够
前端 `generationRef` 只能做到:
```text
旧结果不再写入 UI
```
但后台 Codex 进程仍可能继续:
```text
占 CPU
占 token
占文件句柄
占 child process
```
真正取消必须让 signal 穿过:
```text
UI Stop
↓ Controller
AbortController
↓ Server/RPC
Runtime
turn/interrupt 或 close child
```
### 工业级范式
区分:
```text
stale-result suppression
vs
underlying-work cancellation
```
两个都要有。
---
## 12. `try / finally`:资源释放比成功路径更重要
源码:
```ts
try {
// initialize + turn
} catch (error) {
throw normalizeCodexRuntimeError(error)
} finally {
input.signal?.removeEventListener('abort', abortHandler)
await connection.close()
}
```
### 为什么 `finally` 是 Runtime 核心
以下路径都必须释放:
```text
正常完成
RPC error
JSON parse error
用户 abort
app-server crash
mapper throw
consumer 提前停止 generator
```
只在“成功结束”时 close 是典型资源泄漏。
### 工业级检查表
每一种资源都问:
```text
谁创建?
谁拥有?
谁关闭?
异常路径是否关闭?
取消路径是否关闭?
关闭本身失败怎么办?
```
---
## 13. `close()` 的生产风险:kill 不等于可靠退出
当前:
```ts
this.child.once('exit', finish)
this.child.kill()
```
这是 V0 合理实现,但生产需要考虑:
```text
SIGTERM 后 child 不退出怎么办?
close 等待是否可能永久 pending
是否需要 grace period
是否最终 SIGKILL
Windows signal 行为是否一致?
```
推荐状态:
```text
request graceful stop
↓ timeout
SIGTERM
↓ timeout
SIGKILL / platform equivalent
record forced termination metric
```
不要让 cleanup 本身成为无限等待点。
---
## 14. Error Normalization:不要把底层错误字符串当领域模型
`codex.errors.ts` 定义:
```text
INVALID_INPUT
INVALID_WORKSPACE
AUTH_REQUIRED
RUNTIME_START_FAILED
CODEX_RUNTIME_FAILED
```
### 为什么要分类
上层真正关心的是:
```text
用户能不能修复?
应该重试吗?
应该引导登录吗?
是配置问题还是 Runtime crash
```
而不是底层字符串:
```text
spawn ENOENT
401 unauthorized
some provider-specific message
```
### 当前风险
当前部分分类依赖字符串匹配:
```ts
normalized.includes('authentication')
normalized.includes('401')
```
这在 V0 可用,但长期脆弱。
### 工业级范式
优先级:
```text
结构化 provider error code
> exit code / typed error
> protocol status
> 最后才是字符串 heuristic
```
并保持:
```text
internal detailed error
!=
public browser-safe error
```
---
## 15. BackpressureAsyncGenerator 不是“无限快地推”
`streamTurn()` 是:
```ts
async *streamTurn(...) {
yield event
}
```
消费者通过:
```ts
for await (const event of source) {
// consume
}
```
### 心智模型
```text
producer yield
consumer next()
producer resumes
```
这提供天然的 cooperative backpressure。
但要注意:底层 app-server 仍持续写 stdout。如果 UI 或 RPC 消费明显慢于 provider,仍需要考虑:
- pipe buffer
- 内存队列;
- event batching
- slow consumer metrics。
AsyncGenerator 能改善模型,但不会自动解决所有流控问题。
---
## 16. 当前实现最值得继续演进的三个点
### 16.1 单 reader dispatcher
从顺序 `request()` 消费升级到:
```text
one reader
+ pending request map
+ notification channel
+ server-request router
```
### 16.2 显式 timeout
至少对:
```text
spawn/init timeout
request timeout
turn idle timeout
close timeout
```
分别定义策略。
### 16.3 Runtime Supervisor
process-per-turn 简单、安全、易回收,但启动成本高。
未来长连接模式可引入:
```text
RuntimeSupervisor
├─ connection health
├─ pending requests
├─ thread sessions
├─ restart policy
└─ graceful shutdown
```
但只有在性能数据证明 process-per-turn 成为瓶颈后再引入。
---
## 17. 调试路径
遇到“没有流式输出”时按顺序检查:
```text
1. child 是否成功 spawn
2. stderr 是否已有错误
3. initialize response 是否成功
4. thread/start|resume 是否返回 thread id
5. turn/start response 是否成功
6. turnResult.notifications 是否已有 delta
7. nextNotification 是否继续收到消息
8. 是否真的出现 item/agentMessage/delta
9. map/normalize 是否丢掉事件
10. finally 是否过早 close
```
不要一开始就看 React。
如果 Runtime 根本没有 delta,前端不可能“优化”出真流式。
---
## 18. 复习检查表
- [ ] 能解释为什么用 `spawn()` 而不是一次性 `exec()`
- [ ] 能解释为什么 stdout chunk 不能直接当 JSON message。
- [ ] 能区分 Request / Response / Notification / Server Request。
- [ ] 能解释 request id correlation 的必要性。
- [ ] 能指出当前连接为什么还不是完整并发 dispatcher。
- [ ] 能解释 initialize / initialized 是状态机而不是礼貌握手。
- [ ] 能说明 mapper 为什么属于安全边界。
- [ ] 能解释 delta 与 completed snapshot 的职责差异。
- [ ] 能区分 generation guard 与真正 Abort。
- [ ] 能解释 `finally` 为什么是 Runtime 正确性的核心。
- [ ] 能说出 child process close 需要哪些 timeout/fallback。
- [ ] 能解释 typed error 比字符串错误更适合跨层传播。
- [ ] 能解释 AsyncGenerator 提供什么 backpressure,又不提供什么。
---
## 19. 思考题
1. 如果同一个 App Server connection 上同时运行两个 turn,当前 `request()` 会有什么并发风险?
2. 如果 `item/completed` 文本与所有 delta 拼接结果不同,哪一个应该成为最终状态?为什么?
3. 如果 Browser 断开连接但 server 没有收到 AbortSignalchild process 会发生什么?
4. 为什么 MCP result 应该采用 allow-list mapping,而不是 `...rawItem`
5. Runtime Supervisor 引入后,哪些状态应该进 supervisor,哪些仍应保持 request-scoped
6. 什么时候 process-per-turn 的简单性比长连接性能更重要?
+896
View File
@@ -0,0 +1,896 @@
# 端到端总复习:一条用户消息如何穿过整个 Agent 系统
> 这篇不是第五个孤立专题,而是对前四篇的收口。目标是回答一个工程问题:**用户点击发送以后,系统到底发生了什么;每一层为什么存在;哪里最容易出错;哪些模式值得迁移到别的 Agent 产品。**
---
## 1. 先看全链路
```mermaid
sequenceDiagram
participant U as User
participant UI as React UI
participant C as useChatController
participant SF as TanStack Start ServerFn
participant B as Streaming Bridge
participant R as CodexRuntime
participant A as codex app-server
participant S as chatReducer
participant LS as localStorage
U->>UI: 输入 prompt
UI->>C: sendMessage(prompt)
C->>S: user.message.added
C->>S: turn.started
C->>SF: streamChat({message, threadId})
SF->>B: streamNormalizedChatEvents()
B->>R: streamTurn()
R->>A: initialize / initialized
alt 没有 threadId
R->>A: thread/start
else 已有 threadId
R->>A: thread/resume(threadId)
end
A-->>R: canonical thread id
R-->>B: thread.started
B-->>C: ChatEvent(thread.started)
C->>S: event.received
R->>A: turn/start
A-->>R: item/started(agentMessage)
R-->>B: item.started
B-->>C: assistant.started
C->>S: 创建空 assistant message
loop 文本生成
A-->>R: item/agentMessage/delta
R-->>B: assistant delta
B-->>C: assistant.delta
C->>S: append delta
S-->>UI: React re-render
end
A-->>R: item/completed(agentMessage)
B-->>C: assistant.completed(full text)
C->>S: final reconciliation
A-->>R: tokenUsage / turn.completed
B-->>C: turn.completed
C->>S: running -> idle
S-->>LS: persist threadId + transcript
```
这条链路可以压缩成一句话:
```text
用户意图
→ Server RPC
→ Provider Protocol
→ Application Event
→ State Event
→ Deterministic State
→ UI Projection
```
真正稳定的地方不是“Codex 能生成文本”,而是每一次跨边界都发生了**协议收敛**。
---
## 2. 第一阶段:React 不直接“请求答案”,而是启动一个 Turn
`useChatController()` 发送消息时首先做两件本地状态变化:
```text
user.message.added
turn.started
```
这样 UI 不需要等待网络:用户消息立即出现,输入区进入 running 状态。
传统聊天应用容易写成:
```ts
const answer = await request(prompt)
setMessages([...messages, answer])
```
Agent 应用更适合:
```text
start turn
consume events
fold events into state
```
因为一个 turn 期间不仅有文本,还可能有 reasoning activity、command、MCP、file change、usage、interrupt 和 error。
### 可迁移原则
> **Agent UI 的最小单位应该是“Turn 生命周期”,而不是“HTTP response”。**
---
## 3. 第二阶段:`createServerFn` 是信任边界,不只是调用语法糖
浏览器调用:
```text
streamChat({ message, threadId })
```
看起来像本地函数,实际跨越 Browser → Server。
这层至少负责三件事:
1. 输入验证;
2. 隐藏 Node-only Runtime
3. 把 async generator 作为流返回。
关键模块边界:
```text
src/server-functions/chat.ts
浏览器可 import 的 RPC declaration
src/server-functions/chat.runtime.server.ts
server-only integration seam
src/server/codex/**
Node child process / protocol / auth boundary
```
### 为什么不能直接 import Runtime
因为浏览器不应该理解:
```text
child_process
stdio
~/.codex
local workspace path
approval request
raw Codex notification
```
### 可迁移原则
> **全栈 TypeScript 的“同语言”不代表“同信任域”。模块边界必须按运行环境和安全责任划分。**
---
## 4. 第三阶段:Runtime 先建立 Codex 会话,再启动 Turn
Runtime 与 `codex app-server --stdio` 通信。
最小生命周期:
```text
spawn
initialize
initialized
thread/start OR thread/resume
turn/start
notifications
turn/completed / failed
close
```
### Thread
长期对话上下文。
```text
Thread
├── Turn 1
├── Turn 2
└── Turn 3
```
浏览器只持久化 `threadId`,用它在后续请求中 resume。
### Turn
一次用户请求对应的一整轮 Agent 工作。
### Item
Turn 内部工作单元:
```text
reasoning
commandExecution
mcpToolCall
fileChange
agentMessage
...
```
### 最重要的纠正
```text
Thread ≠ Message[]
Turn ≠ Assistant Message
Item ≠ Token
```
如果这三个概念混淆,后续做 interrupt、tool timeline、多 Agent、thread history 时一定会返工。
---
## 5. 第四阶段:真流式来自 Provider 的 Delta,不来自前端动画
实际关键事件:
```text
item/started(agentMessage)
item/agentMessage/delta × N
item/completed(agentMessage)
```
映射为应用协议:
```text
assistant.started
assistant.delta
assistant.completed
```
### 为什么不是“打字机动画”?
伪流式:
```text
服务端先拿到完整文本
→ 前端 setInterval 一字一字显示
```
真实流式:
```text
模型产生 delta
→ Runtime 立即收到
→ ServerFn 立即 yield
→ Browser 立即 dispatch
→ React 立即 render
```
两者用户观感可能相似,但工程语义完全不同。真正 delta streaming 会改善首字延迟,也允许同步展示工具执行和中断状态。
---
## 6. 第五阶段:为什么需要 `ChatEvent`
Codex 的原始通知不应该直接进入 React:
```text
item/agentMessage/delta
thread/tokenUsage/updated
turn/completed
...
```
应用先收敛成:
```text
ChatEvent
```
例如:
```text
assistant.started
assistant.delta
assistant.completed
activity.started
activity.updated
activity.completed
turn.completed
error
```
### `ChatEvent` 同时承担三种职责
**协议防腐层**UI 不绑定 Codex。
**安全白名单**raw reasoning、stdout/stderr、MCP payload 不越界。
**产品语言**UI 看见的是“Assistant 开始/增量/完成”,而不是 Provider 的 item 类型。
### 如果未来换 Provider
```text
Codex ─┐
Claude ├─ Runtime Adapter ─> ChatEvent ─> UI
Pi ─┤
Qwen ─┘
```
产品层不需要跟着底层协议重写。
### 可迁移原则
> **第三方协议应该在服务端边界被翻译成 application-owned contract。**
---
## 7. 第六阶段:Transport Event 还不是 Reducer Event
Browser 收到 `ChatEvent` 后,还经过:
```text
toChatStateEvent()
```
原因是网络协议与状态协议关注点不同。
例如:
```text
assistant.started(id)
```
传输层只需要 id。
而状态层可能需要:
```text
assistant.started(id, createdAt)
```
因此:
```text
Provider Event
Runtime Event
ChatEvent transport semantics
↓ adapter
ChatStateEvent state semantics
↓ reducer
ChatState product projection
```
这不是“层太多”,而是每层拥有不同责任。
---
## 8. 第七阶段:Reducer 是事件折叠器
Assistant 的状态机:
```mermaid
stateDiagram-v2
[*] --> Started: assistant.started
Started --> Streaming: assistant.delta
Streaming --> Streaming: assistant.delta
Started --> Completed: assistant.completed
Streaming --> Completed: assistant.completed
Completed --> [*]
```
Turn 状态机:
```mermaid
stateDiagram-v2
Idle --> Running: turn.started
Running --> Running: assistant/activity event
Running --> Idle: turn.completed
Running --> Error: error
Error --> Running: next user turn
```
### Delta 是增量
```text
old content + event.delta
```
### Completed 是最终权威快照
```text
content = event.text
```
这形成一个非常有价值的可靠性模式:
```text
incremental optimistic projection
+
authoritative final reconciliation
```
中间流负责体验,最终 snapshot 负责正确性。
---
## 9. Reducer purity:为什么时间不能偷偷在 reducer 里生成
理想 reducer
```text
(state, event) -> nextState
```
相同输入必须有相同输出。
不能在内部调用:
```text
Date.now()
Math.random()
localStorage
network
```
否则会破坏:
- 可重复测试;
- event replay
- time-travel debugging
- 并发渲染下的可预测性。
当前代码中的 `Date.now()` fallback 是值得继续修正的工程缺口。正确方向是 controller/adapter 在 reducer 外注入时间。
### 可迁移原则
> **Controller 管副作用,Reducer 管确定性状态转换。**
---
## 10. PersistenceUI Transcript 与 Agent Context 是两套真相
浏览器当前保存:
```text
threadId
messages
```
Codex 自己保存:
```text
thread/session context
```
因此:
```text
Browser transcript ≠ Agent context source of truth
```
浏览器 transcript 是产品投影,Codex thread 是执行上下文。
### 刷新页面
```text
localStorage restore messages
threadId restore
下一条消息 thread/resume(threadId)
```
### New Chat
应该理解为:
```text
clear current UI conversation pointer
```
而不是:
```text
delete Codex historical thread
```
这种边界使 UI 生命周期与 Agent 历史生命周期解耦。
---
## 11. Stale Stream`generationRef` 解决了什么,没有解决什么
用户在旧请求还没完全结束时 New Chat:
```text
Old Turn -> late event -> New Conversation
```
如果不防护,旧 delta 会写入新页面。
当前 generation guard
```text
send 时记录 generation
New Chat -> generation++
旧 stream 收到事件 -> generation 不匹配 -> ignore
```
它解决的是:
```text
stale UI write
```
但没有解决:
```text
后台 turn 继续生成
后台 token 继续消耗
child process 继续运行
```
因此:
```text
generation guard ≠ cancellation
```
下一阶段应该增加真正的 `turn/interrupt` 或 signal propagation。
---
## 12. Correlation:为什么长期必须使用 `threadId + turnId`
一个 long-lived app-server 连接上可能同时存在多个 Thread / Turn。
只判断:
```text
message.method === turn/completed
```
长期不够。
应建立:
```ts
ActiveTurn {
threadId
turnId
}
```
并过滤:
```text
notification.threadId == active.threadId
notification.turnId == active.turnId
```
否则会出现:
```text
Turn B 的 completed
误结束 Turn A 的 stream
```
当前 process-per-turn 架构降低了这个风险,但没有从协议语义上消除它。
### 可迁移原则
> **在异步事件系统里,合法事件不代表属于当前操作。必须显式 correlation。**
---
## 13. Safety:权限控制和数据泄漏是两个问题
当前 V0 安全策略:
```text
sandbox: read-only
sandboxPolicy.networkAccess: false
approvalPolicy: never
```
这限制 Agent 能做什么。
但还需要另一条独立防线:限制 Browser 能看到什么。
不应该进入浏览器:
```text
raw reasoning
command stdout/stderr
MCP arguments/results
auth material
arbitrary local paths
raw Provider events
```
所以:
```text
Execution permission boundary
!=
Data exposure boundary
```
即使 Agent 是 read-only,也仍可能读取敏感文件路径或输出,因此 normalizer 仍然必须做字段白名单和错误收敛。
---
## 14. Process LifecycleV0 与长期形态的 trade-off
当前偏向:
```text
1 turn
→ spawn app-server
→ initialize
→ execute
→ close
```
### 优点
- 隔离简单;
- cleanup 清晰;
- 一个进程只服务一轮,事件串线风险较低;
- Demo 容易验证。
### 缺点
- 每轮重复启动和握手;
- interrupt / steer 较难;
- 多线程复用差;
- 不适合复杂 approval / MCP 生命周期。
长期更合理:
```text
Application
long-lived AppServerClient
├── Thread A / Turn 1
├── Thread A / Turn 2
└── Thread B / Turn 1
```
这时必须升级:
```text
single reader loop
pending request map
notification subscribers
thread/turn correlation
interrupt
server request routing
bounded shutdown
```
---
## 15. 错误路径应该怎么走
正常链路很容易理解,真正决定系统质量的是异常链路。
典型故障:
```text
codex binary 不存在
initialize RPC error
thread resume 失败
turn failed
child process crash
stdout 非法 JSON
stderr 爆量
stream disconnect
用户 New Chat
network/browser disconnect
```
推荐错误边界:
```text
Raw Runtime Error
↓ server log / diagnostics
normalizeCodexRuntimeError
Sanitized application error
ChatEvent.error
Reducer status=error
Browser generic message
```
不要把“方便调试”作为把任意 server error 直接发给 Browser 的理由。开发日志与产品错误是两个输出通道。
---
## 16. 调试一条不流式的消息
不要直接猜 React。沿链路逐层定位。
### 第 1 层:Provider 是否产生 delta
检查真实 App Server 序列:
```text
item/started(agentMessage)
item/agentMessage/delta
item/completed(agentMessage)
```
没有 delta,前端不可能真流式。
### 第 2 层:Runtime 是否保留 delta
确认 `item/agentMessage/delta` 被转换成内部 delta event。
### 第 3 层:Normalizer 是否过滤掉?
确认产生:
```text
assistant.delta
```
### 第 4 层:ServerFn 是否逐事件 yield
不能先收集数组再返回。
### 第 5 层:Browser 是否 `for await` 即时 dispatch
### 第 6 层:Reducer 是否 append 而不是覆盖?
### 第 7 层:UI 是否真的根据 messages render
这套排查顺序适用于几乎所有流式 Agent UI。
---
## 17. 测试应该沿同一条链路分层
```text
Unit
- validation
- normalizer
- reducer
- storage
Integration
- fake app-server
- request/response id
- notification buffering
- turn correlation
- process exit / abort
Smoke
- real codex app-server
- real auth
- real delta sequence
E2E
- user send
- streaming render
- refresh resume
- New Chat stale-stream isolation
```
一个常见错误是用很多 reducer unit test 代替 Runtime integration test。测试总数不是覆盖面的替代品。
---
## 18. 这套项目最值得迁移的 8 个模式
1. **Runtime Adapter**:Provider 协议封装在服务端。
2. **Application-owned Event Contract**UI 不消费第三方事件。
3. **Streaming Async Iterator**:全链路不聚合事件。
4. **Started / Delta / Completed Lifecycle**:把流式内容作为实体生命周期建模。
5. **Final Reconciliation**:最终 snapshot 校准增量状态。
6. **Transport Event / State Event 分层**:网络语义与状态语义解耦。
7. **Generation Guard + 真 Cancellation 分治**:一个保护 UI,一个停止资源。
8. **Permission Boundary + Data Boundary 双防线**:能做什么与能看到什么分别控制。
这八个模式比任何单一 Codex API 更有长期价值。
---
## 19. 什么时候应该换技术方案?
### TanStack ServerFn vs SSE
当前 ServerFn async generator 类型整合简单。如果未来需要跨非 TanStack 客户端、独立 API 网关或标准化 HTTP stream,可考虑 SSE。
### SSE vs WebSocket
只需要 Server → Client 连续事件:SSE 足够。
需要真正双向实时控制:
```text
interrupt
steer
approval
实时 tool interaction
```
WebSocket 或长连接协议会更自然。
### process-per-turn vs long-lived App Server
Demo/单用户/隔离优先:process-per-turn 简单。
桌面 Agent/多会话/interrupt/steerlong-lived client 更合理。
不要为了“更先进”过早复杂化;技术切换应该由交互需求和生命周期要求驱动。
---
## 20. 最终复习题
1. 为什么 `runStreamed()` 有事件不代表一定支持 Assistant 正文真流式?
2. Thread、Turn、Item 分别是谁的生命周期?
3. 为什么 `ChatEvent` 是安全边界,而不仅是类型定义?
4. 为什么还要从 `ChatEvent` 转成 `ChatStateEvent`
5. `assistant.completed` 已有完整文本,为什么前面还要处理 delta?
6. 为什么 reducer 内的 `Date.now()` 是工程问题?
7. `generationRef` 为什么不能替代 `turn/interrupt`
8. 为什么 `threadId` 不能完全替代 `turnId` correlation
9. read-only sandbox 能否保证浏览器不会看到敏感路径?为什么?
10. 如果改成长连接 App Server`AppServerConnection` 最需要增加哪些基础设施?
11. 为什么 Browser transcript 不能作为 Agent context 的唯一 source of truth
12. 一个真实 Agent Runtime 的 integration test 应该模拟哪些消息乱序和故障?
如果能脱离源码准确回答这 12 个问题,就已经掌握了这套项目真正有迁移价值的架构知识。
---
## 21. 一页总结
```text
Browser
只拥有产品状态
TanStack Start
建立可信 Server boundary
ChatEvent
建立应用自己的协议
CodexRuntime
隔离 Provider / process / auth
codex app-server
产生 thread / turn / item / delta
```
返回方向:
```text
Provider notification
→ Runtime normalization
→ ChatEvent
→ ChatStateEvent
→ reducer
→ UI projection
→ local persistence
```
可靠性补充:
```text
correlation
cancellation
process cleanup
final reconciliation
error sanitization
fake transport tests
```
安全补充:
```text
read-only / no network / no approval
+
Browser event whitelist
```
最终原则:
> **不要让 UI 理解 Runtime,不要让第三方协议定义产品状态,不要让流式体验牺牲最终一致性,也不要把“能运行”误认为“生命周期已经正确”。**
+128
View File
@@ -0,0 +1,128 @@
# 专业技术笔记索引
这套笔记用于长期复习 `Codex + TanStack Start` 本地 Agent 架构。它与 `docs/tasks/*``DEVELOPMENT_PLAN.md``VALIDATION.md` 的定位不同:后者记录“项目怎么做、当时做到了什么”,这里回答“为什么这样做、这些知识如何迁移到其他 Agent 工程”。
## 推荐阅读顺序
| 顺序 | 笔记 | 主要问题 | 学习目标 |
|---|---|---|---|
| 1 | [01-system-architecture.md](./01-system-architecture.md) | 系统边界在哪里? | 建立 Browser / TanStack Start / Runtime / Codex 的整体心智模型,理解 thread / turn / item 与 application-owned event contract |
| 2 | [02-codex-app-server-streaming.md](./02-codex-app-server-streaming.md) | 真流式从哪里来? | 掌握 app-server、stdio/JSON-RPC、`item/agentMessage/delta`、进程生命周期、安全策略与协议适配 |
| 3 | [03-tanstack-streaming-state.md](./03-tanstack-streaming-state.md) | 流式事件如何变成稳定 UI? | 掌握 `createServerFn`、async generator、Transport/State contract、reducer 状态机、stale stream 与 persistence |
| 4 | [04-testing-reliability-security.md](./04-testing-reliability-security.md) | “能跑”为什么不等于“可靠”? | 掌握 correlation、abort/interrupt、fake transport、child process、错误收敛、安全边界和故障注入 |
| 5 | [05-end-to-end-review.md](./05-end-to-end-review.md) | 一条消息完整经历了什么? | 把前四篇重新串成端到端模型,并提炼可迁移的工程模式 |
## 先修知识
建议先具备以下基础,再阅读效果最好:
- TypeScriptunion type、type narrowing、async iterator、`AsyncGenerator`
- Reactrender/commit、state snapshot、`useReducer``useRef`、副作用边界;
- Node.js`child_process.spawn`、stdio、process signal
- WebRPC、streaming、SSE/WebSocket 的基本区别;
- Agentconversation/session、tool call、streaming response 的基本概念。
如果只想快速理解项目,从 **01 → 05** 即可;如果要修改 Runtime,必须完整读 **02 + 04**;如果主要维护 Web/UI,重点读 **03 + 05**
## 统一术语
整套笔记统一采用下面的含义:
```text
Thread = Codex 长期会话上下文
Turn = Thread 内一次用户请求对应的一轮 Agent 执行
Item = Turn 内部的工作单元
Delta = Assistant 正文增量
Snapshot = 某个时刻/完成态的完整正文
ChatEvent = Server -> Browser 的应用级传输协议
ChatStateEvent = 进入 reducer 的状态事件
Runtime = 对具体 Agent 后端的适配层
```
事件命名以当前应用协议为准:
```text
thread.started
assistant.started
assistant.delta
assistant.completed
activity.started
activity.updated
activity.completed
turn.completed
error
```
Codex 原始协议只在 Runtime/协议专题中出现,例如:
```text
thread/start
thread/resume
turn/start
item/started
item/agentMessage/delta
item/completed
thread/tokenUsage/updated
turn/completed
```
不要把两组名字混用:前者是应用协议,后者是 Provider 协议。
## 阅读方法
每篇都按相同模板组织:
1. 背景与问题;
2. 心智模型;
3. 生命周期 / 数据流 / 状态机;
4. 当前源码映射;
5. 为什么这样设计;
6. 替代方案与 trade-off
7. 失败模式;
8. 调试方法;
9. 测试策略;
10. 可迁移结论;
11. 复习题。
阅读时不要只记 API。优先回答三个问题:
```text
这层拥有什么状态?
这层信任哪些数据?
这层允许把什么信息交给下一层?
```
这三个问题比记住某个函数名更能帮助你迁移到 Claude、Pi、Qwen、OpenCode 或其他 Runtime。
## 文档分区
### 学习笔记
`docs/notes/*` 是长期维护的知识文档。要求与当前源码一致,并解释设计理由和适用边界。
### 项目记录
以下文件保留为历史/实施记录,不作为系统学习入口:
- `docs/ARCHITECTURE.md`V0 架构摘要;
- `docs/INTEGRATION.md`Task 05 集成记录;
- `docs/DEVELOPMENT_PLAN.md`:五任务开发拆分;
- `docs/VALIDATION.md`:某次集成验证快照;
- `docs/tasks/*`:各开发任务的实施说明。
项目记录可以保留当时的事实,但不应承担“专业知识笔记”的职责。
## 当前源码审阅重点
当前文档已经明确区分“已经实现”和“应继续完善”的内容。阅读时尤其注意:
- 真流式来自 `item/agentMessage/delta`
- `item/completed` 的完整文本用于最终校准;
- 当前安全策略是 read-only、network disabled、approval never
- Browser 不能收到 raw reasoning、command output、MCP 参数/result 或本地认证信息;
- `generationRef` 解决 stale UI write,但不等于真正中断底层 turn;
- Runtime 长期形态应使用 `threadId + turnId` 做 correlation
- reducer 应保持纯函数,时间/随机数/IO 必须在 reducer 外生成;
- process-per-turn 对 V0 简单可靠,但 long-lived app-server 更适合 interrupt、steer、多 thread 和 approval。
这些“缺口”不是文档错误,而是当前实现与下一阶段工程化之间的边界。
+25
View File
@@ -0,0 +1,25 @@
# `docs/tasks` 文档定位
本目录保存项目开发阶段的**任务实施记录**,用于回答:某个阶段负责什么、交付了哪些模块、当时有哪些约束。
它不是长期学习笔记目录。专业技术学习入口请使用:
- [`../notes/README.md`](../notes/README.md) — 总索引与阅读路线;
- [`../notes/01-system-architecture.md`](../notes/01-system-architecture.md) — 系统架构;
- [`../notes/02-codex-app-server-streaming.md`](../notes/02-codex-app-server-streaming.md) — Codex App Server 与真实流式;
- [`../notes/03-tanstack-streaming-state.md`](../notes/03-tanstack-streaming-state.md) — TanStack Start 与 React 状态;
- [`../notes/04-testing-reliability-security.md`](../notes/04-testing-reliability-security.md) — 测试、可靠性、安全;
- [`../notes/05-end-to-end-review.md`](../notes/05-end-to-end-review.md) — 端到端总复习。
## 本目录保留的文件
| 文件 | 定位 |
|---|---|
| `01-codex-runtime.md` | Runtime 开发任务说明 |
| `02-streaming-bridge.md` | Streaming bridge 开发任务说明 |
| `02-integration-notes.md` | Task 02 集成过程记录 |
| `03-chat-ui.md` | UI 开发任务说明 |
| `04-state-persistence-tests.md` | State / persistence / tests 开发任务说明 |
| `05-integration.md` | 最终集成任务说明 |
这些文件可以保留历史事实,但后续不要继续向其中堆叠概念教程。新的原理性内容应优先写入 `docs/notes/`,并从任务记录链接过去。