Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
294ecae6f2 | ||
|
|
eacc20467a | ||
|
|
8412831f53 | ||
|
|
b9b4eb9d4a | ||
|
|
8632e0702e |
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,931 @@
|
||||
# Codex + TanStack Start:系统架构学习笔记
|
||||
|
||||
> 目标:这不是项目进度记录,而是一篇可以脱离当前上下文独立复习的架构笔记。它回答三个问题:**系统边界在哪里、数据如何流动、为什么要这样分层。**
|
||||
|
||||
## 1. 问题背景:我们真正要解决什么
|
||||
|
||||
这个项目不是在“做一个聊天框”,而是在验证一套本地 Agent Web 架构:浏览器负责交互,TanStack Start 负责 Web/RPC 边界,Codex app-server 负责 Agent runtime,本机已有的 Codex/ChatGPT 登录态负责认证。
|
||||
|
||||
核心约束有四个:
|
||||
|
||||
1. 浏览器不能拿到 Codex 凭据、`~/.codex`、原始协议对象或本地敏感信息。
|
||||
2. Agent 回复必须是真正的流式文本,而不是请求完成后一次性返回。
|
||||
3. Web 层不能直接绑定 Codex 协议,否则以后换 Claude、Pi、Qwen 或其他 runtime 会牵动整个 UI。
|
||||
4. V0 必须保持只读:可以分析仓库,但不允许文件写入、网络访问或交互式审批绕过。
|
||||
|
||||
因此,架构目标不是“最少代码”,而是建立几个清晰的边界:
|
||||
|
||||
```text
|
||||
Browser/UI boundary
|
||||
Transport/RPC boundary
|
||||
Application event boundary
|
||||
Agent runtime boundary
|
||||
Local machine / workspace boundary
|
||||
```
|
||||
|
||||
真正重要的是:每一层只理解自己需要理解的协议。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[User] --> UI[React Chat UI]
|
||||
UI --> C[useChatController]
|
||||
C --> SF[TanStack Start createServerFn]
|
||||
SF --> BR[Streaming Bridge]
|
||||
BR --> N[Codex Event Normalizer]
|
||||
N --> RT[CodexRuntime interface]
|
||||
RT --> AR[CodexAppServerRuntime]
|
||||
AR --> AS[codex app-server]
|
||||
AS --> FS[Workspace / Git Repository]
|
||||
AS --> AUTH[Local Codex / ChatGPT Auth]
|
||||
|
||||
N -->|ChatEvent| BR
|
||||
BR -->|Async stream| SF
|
||||
SF --> C
|
||||
C --> AD[ChatEvent adapter]
|
||||
AD --> R[chatReducer]
|
||||
R --> UI
|
||||
```
|
||||
|
||||
从浏览器看,它只知道:
|
||||
|
||||
```text
|
||||
ChatRequest -> stream<ChatEvent>
|
||||
```
|
||||
|
||||
它不知道:
|
||||
|
||||
```text
|
||||
JSON-RPC
|
||||
codex app-server
|
||||
thread/start
|
||||
turn/start
|
||||
item/agentMessage/delta
|
||||
~/.codex
|
||||
子进程
|
||||
stdio
|
||||
```
|
||||
|
||||
这就是架构中的第一原则:**把基础设施协议封装成应用协议。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 三层系统边界
|
||||
|
||||
### 3.1 Browser:只负责产品状态
|
||||
|
||||
浏览器职责:
|
||||
|
||||
- 收集用户输入;
|
||||
- 调用 `streamChat()`;
|
||||
- 消费 `ChatEvent`;
|
||||
- 维护消息、运行状态、活动状态;
|
||||
- 保存最小会话信息;
|
||||
- 渲染 UI。
|
||||
|
||||
浏览器不应该:
|
||||
|
||||
- 调用 `codex app-server`;
|
||||
- 读取 Codex 登录文件;
|
||||
- 接触 MCP 参数/result;
|
||||
- 接触 command stdout/stderr;
|
||||
- 接触 reasoning 原文;
|
||||
- 决定 sandbox 权限。
|
||||
|
||||
**前端状态不是 Agent runtime 状态。** 前端保存的是“产品需要展示的投影”。
|
||||
|
||||
### 3.2 TanStack Start Server:安全网关 + 协议翻译层
|
||||
|
||||
Server 层做三类工作:
|
||||
|
||||
1. 输入验证;
|
||||
2. runtime 调用;
|
||||
3. 将 runtime 事件变成浏览器安全的应用事件。
|
||||
|
||||
`src/server-functions/chat.ts` 很关键,因为它是浏览器可导入模块,但真正的 Codex runtime 被隔离在 `*.server.ts` 后面。
|
||||
|
||||
```text
|
||||
client import
|
||||
|
|
||||
v
|
||||
createServerFn()
|
||||
|
|
||||
| server execution only
|
||||
v
|
||||
chat.runtime.server.ts
|
||||
|
|
||||
v
|
||||
Codex runtime
|
||||
```
|
||||
|
||||
这解决了一个典型全栈框架问题:**同一个 TypeScript 工程不等于所有模块都可以进入 browser bundle。**
|
||||
|
||||
### 3.3 Codex Runtime:负责 Agent 生命周期
|
||||
|
||||
Runtime 层负责:
|
||||
|
||||
- spawn `codex app-server --stdio`;
|
||||
- initialize;
|
||||
- start/resume thread;
|
||||
- start turn;
|
||||
- 接收 item notification;
|
||||
- 接收 assistant delta;
|
||||
- 收集 usage;
|
||||
- 清理进程;
|
||||
- 把协议对象转换为内部 `CodexThreadEvent`。
|
||||
|
||||
这里最重要的架构点不是 Codex,而是 `CodexRuntime` interface。
|
||||
|
||||
```ts
|
||||
interface CodexRuntime {
|
||||
streamTurn(input: StreamCodexTurnInput): AsyncGenerator<CodexThreadEvent>
|
||||
}
|
||||
```
|
||||
|
||||
UI 不依赖 `CodexAppServerRuntime`,server function 也不应该依赖 JSON-RPC 细节。
|
||||
|
||||
---
|
||||
|
||||
## 4. thread / turn / item:必须建立的心智模型
|
||||
|
||||
这是理解 Codex Agent runtime 的基础。
|
||||
|
||||
### Thread
|
||||
|
||||
`thread` 是长期会话上下文。
|
||||
|
||||
它类似:
|
||||
|
||||
```text
|
||||
Conversation / Agent Session
|
||||
```
|
||||
|
||||
第一条消息:
|
||||
|
||||
```text
|
||||
thread/start
|
||||
```
|
||||
|
||||
后续继续聊天:
|
||||
|
||||
```text
|
||||
thread/resume(threadId)
|
||||
```
|
||||
|
||||
浏览器 localStorage 保存 `threadId` 的原因,就是要把产品侧会话重新连接到 Codex 侧的长期上下文。
|
||||
|
||||
### Turn
|
||||
|
||||
`turn` 是 thread 中一次用户输入对应的一轮 Agent 工作。
|
||||
|
||||
```text
|
||||
Thread
|
||||
├── Turn 1
|
||||
├── Turn 2
|
||||
└── Turn 3
|
||||
```
|
||||
|
||||
一次 turn 可能包含:
|
||||
|
||||
- reasoning;
|
||||
- command execution;
|
||||
- MCP tool call;
|
||||
- web search;
|
||||
- assistant message;
|
||||
- token usage;
|
||||
- error。
|
||||
|
||||
因此:
|
||||
|
||||
> 一个 HTTP/RPC 请求不等于一个 assistant message,而更接近一个完整 turn。
|
||||
|
||||
### Item
|
||||
|
||||
`item` 是 turn 内部的工作单元。
|
||||
|
||||
```text
|
||||
Turn
|
||||
├── reasoning item
|
||||
├── command item
|
||||
├── tool item
|
||||
└── agentMessage item
|
||||
```
|
||||
|
||||
Assistant 文本本身也是一个 item,它有生命周期:
|
||||
|
||||
```text
|
||||
item/started
|
||||
↓
|
||||
item/agentMessage/delta × N
|
||||
↓
|
||||
item/completed
|
||||
```
|
||||
|
||||
这也是为什么 UI 应使用:
|
||||
|
||||
```text
|
||||
assistant.started
|
||||
assistant.delta
|
||||
assistant.completed
|
||||
```
|
||||
|
||||
而不是只使用一个:
|
||||
|
||||
```text
|
||||
assistant.message
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 一条消息的完整生命周期
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant React
|
||||
participant Start as TanStack Start
|
||||
participant Runtime
|
||||
participant Codex as codex app-server
|
||||
|
||||
User->>React: 输入 prompt
|
||||
React->>Start: streamChat({message, threadId})
|
||||
Start->>Runtime: streamTurn()
|
||||
Runtime->>Codex: initialize
|
||||
Codex-->>Runtime: initialize result
|
||||
Runtime->>Codex: initialized
|
||||
|
||||
alt 新会话
|
||||
Runtime->>Codex: thread/start
|
||||
else 已有会话
|
||||
Runtime->>Codex: thread/resume(threadId)
|
||||
end
|
||||
|
||||
Codex-->>Runtime: thread id
|
||||
Runtime-->>React: thread.started
|
||||
|
||||
Runtime->>Codex: turn/start
|
||||
Codex-->>Runtime: item/started(agentMessage)
|
||||
Runtime-->>React: assistant.started
|
||||
|
||||
loop 模型生成文本
|
||||
Codex-->>Runtime: item/agentMessage/delta
|
||||
Runtime-->>React: assistant.delta
|
||||
React->>React: reducer append delta
|
||||
end
|
||||
|
||||
Codex-->>Runtime: item/completed(agentMessage)
|
||||
Runtime-->>React: assistant.completed(full snapshot)
|
||||
Codex-->>Runtime: thread/tokenUsage/updated
|
||||
Codex-->>Runtime: turn/completed
|
||||
Runtime-->>React: turn.completed
|
||||
```
|
||||
|
||||
这里有两个特别值得记住的设计:
|
||||
|
||||
### Delta 用于体验
|
||||
|
||||
`assistant.delta` 提供实时输出。
|
||||
|
||||
### Completed snapshot 用于校准
|
||||
|
||||
最终 `item/completed` 里的完整文本不是多余的。
|
||||
|
||||
它可以校准:
|
||||
|
||||
- delta 丢失;
|
||||
- delta 重复;
|
||||
- snapshot 修订;
|
||||
- 中间状态异常。
|
||||
|
||||
设计原则:
|
||||
|
||||
```text
|
||||
delta = 实时体验
|
||||
completed snapshot = 最终事实
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 为什么需要 application-owned `ChatEvent`
|
||||
|
||||
这是整个架构最值得迁移到其他项目的设计之一。
|
||||
|
||||
如果 UI 直接消费 Codex:
|
||||
|
||||
```ts
|
||||
if (event.method === 'item/agentMessage/delta') { ... }
|
||||
```
|
||||
|
||||
那么 UI 已经被 Codex 协议绑死。
|
||||
|
||||
现在使用:
|
||||
|
||||
```ts
|
||||
ChatEvent =
|
||||
| assistant.started
|
||||
| assistant.delta
|
||||
| assistant.completed
|
||||
| activity.started
|
||||
| activity.updated
|
||||
| activity.completed
|
||||
| turn.completed
|
||||
| error
|
||||
```
|
||||
|
||||
于是关系变成:
|
||||
|
||||
```text
|
||||
Codex protocol
|
||||
↓ adapter
|
||||
Application protocol
|
||||
↓
|
||||
React UI
|
||||
```
|
||||
|
||||
未来换 runtime:
|
||||
|
||||
```text
|
||||
Claude events ─┐
|
||||
Pi events ├─> ChatEvent ─> UI
|
||||
Qwen events ─┘
|
||||
```
|
||||
|
||||
UI 不变。
|
||||
|
||||
### `ChatEvent` 不是简单 DTO
|
||||
|
||||
它承担三个职责:
|
||||
|
||||
1. **解耦**:隔离供应商协议;
|
||||
2. **安全**:只允许白名单数据进入浏览器;
|
||||
3. **产品语义**:把 Agent 底层事件转换成 UI 真正关心的生命周期。
|
||||
|
||||
这比“直接透传原始 event,然后前端自己判断”健壮得多。
|
||||
|
||||
---
|
||||
|
||||
## 7. 为什么还要有 `ChatStateEvent`
|
||||
|
||||
项目里实际上有两个事件层:
|
||||
|
||||
```text
|
||||
ChatEvent -> transport contract
|
||||
ChatStateEvent -> reducer contract
|
||||
```
|
||||
|
||||
它们不是重复设计。
|
||||
|
||||
例如 server 只需要告诉浏览器:
|
||||
|
||||
```text
|
||||
assistant.started(id)
|
||||
```
|
||||
|
||||
而 reducer 创建消息时需要:
|
||||
|
||||
```text
|
||||
createdAt
|
||||
```
|
||||
|
||||
于是 adapter 可以在客户端补产品状态需要的信息:
|
||||
|
||||
```text
|
||||
ChatEvent
|
||||
↓ toChatStateEvent(receivedAt)
|
||||
ChatStateEvent
|
||||
↓
|
||||
Reducer
|
||||
```
|
||||
|
||||
这遵循一个很重要的原则:
|
||||
|
||||
> Transport model、domain model、view state model 不应因为字段看起来相似就强行合并。
|
||||
|
||||
---
|
||||
|
||||
## 8. Server-only 边界为什么重要
|
||||
|
||||
全栈 TypeScript 很容易制造一种错觉:
|
||||
|
||||
> “既然都是 TS 文件,直接 import 不就行了吗?”
|
||||
|
||||
问题是 browser bundle 一旦导入 runtime 模块,就可能把:
|
||||
|
||||
- Node API;
|
||||
- 本地路径;
|
||||
- 子进程逻辑;
|
||||
- 协议类型;
|
||||
- 甚至认证相关实现
|
||||
|
||||
带进浏览器构建图。
|
||||
|
||||
当前项目的边界是:
|
||||
|
||||
```text
|
||||
chat.ts browser-importable RPC declaration
|
||||
chat.runtime.server.ts server-only seam
|
||||
src/server/codex/** server-only runtime
|
||||
```
|
||||
|
||||
`createServerFn` 的价值之一,就是让客户端引用一个“函数形状”,实际执行发生在服务器。
|
||||
|
||||
---
|
||||
|
||||
## 9. 持久化边界:谁保存什么
|
||||
|
||||
当前有两套状态所有者。
|
||||
|
||||
### Browser 持久化
|
||||
|
||||
只保存:
|
||||
|
||||
```text
|
||||
threadId
|
||||
messages[]
|
||||
```
|
||||
|
||||
这解决的是产品体验:刷新页面后仍然能看到聊天记录并继续原线程。
|
||||
|
||||
### Codex 持久化
|
||||
|
||||
Codex 自己维护 thread/session 数据,例如本地 session。
|
||||
|
||||
这解决的是 Agent 上下文。
|
||||
|
||||
两者不能混为一谈:
|
||||
|
||||
```text
|
||||
Browser transcript ≠ Codex thread state
|
||||
```
|
||||
|
||||
浏览器消息只是 UI 投影,不应该被当成 Agent 的唯一真实上下文。
|
||||
|
||||
### New Chat 的语义
|
||||
|
||||
当前 New Chat 做的是:
|
||||
|
||||
```text
|
||||
清浏览器 active conversation
|
||||
```
|
||||
|
||||
不是:
|
||||
|
||||
```text
|
||||
删除 Codex 历史 session
|
||||
```
|
||||
|
||||
这是正确的职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 10. V0 read-only 安全模型
|
||||
|
||||
当前策略:
|
||||
|
||||
```text
|
||||
model: luna
|
||||
effort: high
|
||||
sandbox: read-only
|
||||
sandboxPolicy.networkAccess: false
|
||||
approvalPolicy: never
|
||||
```
|
||||
|
||||
安全思路不是依赖一个开关,而是多层防线。
|
||||
|
||||
### 第一层:权限限制
|
||||
|
||||
```text
|
||||
read-only sandbox
|
||||
network disabled
|
||||
```
|
||||
|
||||
### 第二层:无交互审批
|
||||
|
||||
```text
|
||||
approvalPolicy: never
|
||||
```
|
||||
|
||||
避免 runtime 临时要求更高权限后由 Web UI 放行。
|
||||
|
||||
### 第三层:数据最小化
|
||||
|
||||
浏览器不接收:
|
||||
|
||||
```text
|
||||
reasoning raw text
|
||||
command stdout/stderr
|
||||
MCP arguments/results
|
||||
auth data
|
||||
raw runtime event
|
||||
```
|
||||
|
||||
### 第四层:workspace root
|
||||
|
||||
工作目录需要 canonicalize,并限制在允许 root 下。
|
||||
|
||||
安全原则:
|
||||
|
||||
> Agent 的权限控制和 UI 的数据脱敏是两个不同问题,二者都必须做。
|
||||
|
||||
只读 sandbox 防止 Agent 修改系统;事件白名单防止敏感信息泄漏到 Browser。
|
||||
|
||||
---
|
||||
|
||||
## 11. 为什么不直接把 Codex 协议给前端
|
||||
|
||||
看起来直接转发 JSON-RPC 最省代码:
|
||||
|
||||
```text
|
||||
Codex -> WebSocket -> Browser
|
||||
```
|
||||
|
||||
但会带来几个长期问题。
|
||||
|
||||
| 方案 | 优点 | 代价 |
|
||||
|---|---|---|
|
||||
| 原始协议直传 | 开发快、信息完整 | 前端强耦合 Codex、敏感字段难控制、协议升级影响 UI |
|
||||
| Server normalize | 安全边界清楚、UI 稳定、可换 runtime | server adapter 代码更多 |
|
||||
| Browser 自己 adapter | server 简单 | 安全与兼容逻辑散落前端,不推荐 |
|
||||
|
||||
对于 Agent 产品,推荐:
|
||||
|
||||
```text
|
||||
Raw runtime protocol
|
||||
↓
|
||||
Server-side normalization
|
||||
↓
|
||||
Stable app protocol
|
||||
```
|
||||
|
||||
因为 runtime event 通常比 UI 所需的数据丰富得多。
|
||||
|
||||
---
|
||||
|
||||
## 12. 当前关键源码映射
|
||||
|
||||
| 关注点 | 文件 | 职责 |
|
||||
|---|---|---|
|
||||
| Web RPC | `src/server-functions/chat.ts` | 输入校验、streaming server function、错误收敛 |
|
||||
| Runtime seam | `src/server-functions/chat.runtime.server.ts` | Web 层进入 Codex 层的唯一桥梁 |
|
||||
| Streaming bridge | `src/server-functions/chat-stream.ts` | 消费 runtime event 并输出 ChatEvent |
|
||||
| Event normalization | `src/server-functions/codex-event-normalizer.ts` | Codex event -> application event |
|
||||
| Runtime contract | `src/server/codex/codex-runtime.ts` | 定义可替换 runtime interface |
|
||||
| App-server client | `src/server/codex/codex-app-server.server.ts` | 子进程、JSON-RPC、thread/turn/item 生命周期 |
|
||||
| Workspace policy | `src/server/codex/workspace.server.ts` | 工作目录约束 |
|
||||
| App transport types | `src/features/chat/chat.types.ts` | Browser-safe ChatEvent contract |
|
||||
| State adapter | `src/features/chat/chat-event.adapter.ts` | transport event -> reducer event |
|
||||
| Reducer | `src/features/chat/chat.reducer.ts` | 确定性状态变化 |
|
||||
| Controller | `src/features/chat/use-chat-controller.ts` | 调 RPC、消费 stream、generation guard、持久化 |
|
||||
| Storage | `src/features/chat/chat.storage.ts` | localStorage versioned persistence |
|
||||
| UI | `src/features/chat/components/**` | 纯展示与交互 |
|
||||
|
||||
复习源码时推荐按这个顺序:
|
||||
|
||||
```text
|
||||
chat.ts
|
||||
-> chat-stream.ts
|
||||
-> codex-event-normalizer.ts
|
||||
-> codex-runtime.ts
|
||||
-> codex-app-server.server.ts
|
||||
-> chat-event.adapter.ts
|
||||
-> chat.reducer.ts
|
||||
-> use-chat-controller.ts
|
||||
```
|
||||
|
||||
这样是在顺着数据流读,而不是按目录读。
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前架构里的几个关键 trade-off
|
||||
|
||||
### process-per-turn vs long-lived app-server
|
||||
|
||||
当前 `streamTurn()` 每次创建一个 app-server connection。
|
||||
|
||||
优点:
|
||||
|
||||
- 生命周期简单;
|
||||
- 故障隔离强;
|
||||
- turn 结束即可清理;
|
||||
- V0 易调试。
|
||||
|
||||
缺点:
|
||||
|
||||
- 重复 initialize;
|
||||
- 多 turn 成本更高;
|
||||
- interrupt/steer/多并发管理不自然;
|
||||
- 不适合未来复杂 Agent desktop runtime。
|
||||
|
||||
成熟版本通常更适合:
|
||||
|
||||
```text
|
||||
App lifecycle
|
||||
↓
|
||||
long-lived AppServerClient
|
||||
├── Thread A / Turn 1
|
||||
├── Thread A / Turn 2
|
||||
└── Thread B / Turn 1
|
||||
```
|
||||
|
||||
### async generator RPC vs SSE/WebSocket
|
||||
|
||||
当前使用 TanStack Start async generator。
|
||||
|
||||
| 技术 | 适合场景 |
|
||||
|---|---|
|
||||
| async generator RPC | 请求-流式响应、类型整合好、当前 Demo 简洁 |
|
||||
| SSE | 单向服务器推送,协议简单,浏览器原生支持 |
|
||||
| WebSocket | 双向长期会话、interrupt/steer/approval/实时协作 |
|
||||
| raw fetch stream | 控制力高,但协议、解析、类型都需要自己维护 |
|
||||
|
||||
当前需求主要是:
|
||||
|
||||
```text
|
||||
user request -> server stream response
|
||||
```
|
||||
|
||||
因此 async generator 很合理。
|
||||
|
||||
当未来加入:
|
||||
|
||||
```text
|
||||
steer
|
||||
approval
|
||||
interrupt
|
||||
multi-agent events
|
||||
```
|
||||
|
||||
WebSocket 或独立 runtime transport 的价值会提高。
|
||||
|
||||
---
|
||||
|
||||
## 14. 典型失败场景
|
||||
|
||||
### 14.1 Codex 模型不可用
|
||||
|
||||
表现:
|
||||
|
||||
```text
|
||||
turn 很快失败
|
||||
没有 agentMessage delta
|
||||
```
|
||||
|
||||
调试顺序:
|
||||
|
||||
1. 先直接测试本机 Codex;
|
||||
2. 验证账号模型权限;
|
||||
3. 记录 app-server event type,而不是直接猜 UI;
|
||||
4. 检查 `turn/completed` status/error。
|
||||
|
||||
### 14.2 浏览器不是流式输出
|
||||
|
||||
分层排查:
|
||||
|
||||
```text
|
||||
Codex 是否产生 delta?
|
||||
↓ yes
|
||||
Normalizer 是否产生 assistant.delta?
|
||||
↓ yes
|
||||
TanStack stream 是否逐事件到达?
|
||||
↓ yes
|
||||
Reducer 是否 append?
|
||||
↓ yes
|
||||
UI 是否被 memo/render 阻断?
|
||||
```
|
||||
|
||||
不要一开始就在 React 层加“打字机动画”。伪流式会掩盖真正的数据链路问题。
|
||||
|
||||
### 14.3 New Chat 后旧回复出现
|
||||
|
||||
这是 stale stream 问题。
|
||||
|
||||
当前客户端用 generation guard:
|
||||
|
||||
```text
|
||||
stream generation != current generation
|
||||
=> ignore
|
||||
```
|
||||
|
||||
它解决 UI 污染,但不等于 runtime 已取消。
|
||||
|
||||
### 14.4 turn 事件串线
|
||||
|
||||
未来存在并发 turn 时,不能只看:
|
||||
|
||||
```text
|
||||
message.method === 'turn/completed'
|
||||
```
|
||||
|
||||
应该同时关联:
|
||||
|
||||
```text
|
||||
threadId + turnId
|
||||
```
|
||||
|
||||
否则别的 turn completed 可能误结束当前流。
|
||||
|
||||
### 14.5 子进程退出异常
|
||||
|
||||
需要区分:
|
||||
|
||||
```text
|
||||
JSON-RPC error
|
||||
protocol parse error
|
||||
app-server process exit
|
||||
stderr diagnostics
|
||||
user abort
|
||||
turn failure
|
||||
```
|
||||
|
||||
如果全部变成一个 `Error('failed')`,系统后续会很难观测。
|
||||
|
||||
---
|
||||
|
||||
## 15. 调试方法:按边界观察,而不是全链路乱打日志
|
||||
|
||||
推荐在开发期临时记录结构化信息:
|
||||
|
||||
```text
|
||||
method
|
||||
threadId
|
||||
turnId
|
||||
item.type
|
||||
item.id
|
||||
delta.length
|
||||
status
|
||||
```
|
||||
|
||||
不要记录:
|
||||
|
||||
```text
|
||||
完整 reasoning
|
||||
command output
|
||||
MCP payload
|
||||
auth token
|
||||
敏感文件内容
|
||||
```
|
||||
|
||||
### 一条标准调试链
|
||||
|
||||
```text
|
||||
1. 本机 codex CLI 是否正常
|
||||
2. app-server initialize 是否成功
|
||||
3. thread/start 或 resume 是否成功
|
||||
4. turn/start 是否返回
|
||||
5. item/started 是否出现
|
||||
6. agentMessage delta 是否持续出现
|
||||
7. item/completed 是否包含最终 snapshot
|
||||
8. turn/completed 是否正确
|
||||
9. ChatEvent 是否正确映射
|
||||
10. reducer 是否按 id 更新
|
||||
```
|
||||
|
||||
这是比“浏览器没显示,先看 React”更专业的定位方式。
|
||||
|
||||
---
|
||||
|
||||
## 16. 架构演进路线
|
||||
|
||||
### Stage 1:当前 V0
|
||||
|
||||
```text
|
||||
read-only
|
||||
single workspace
|
||||
single active browser conversation
|
||||
process-per-turn
|
||||
streaming assistant
|
||||
```
|
||||
|
||||
### Stage 2:可靠 runtime client
|
||||
|
||||
增加:
|
||||
|
||||
- `turnId` correlation;
|
||||
- `turn/interrupt`;
|
||||
- 强制进程退出兜底;
|
||||
- fake app-server integration tests;
|
||||
- 结构化错误类型。
|
||||
|
||||
### Stage 3:长期 app-server
|
||||
|
||||
```text
|
||||
one runtime process
|
||||
multiple threads
|
||||
multiple turns
|
||||
```
|
||||
|
||||
增加:
|
||||
|
||||
- turn registry;
|
||||
- notification routing;
|
||||
- concurrency control;
|
||||
- reconnect/recovery。
|
||||
|
||||
### Stage 4:可写 Agent
|
||||
|
||||
必须新增:
|
||||
|
||||
- approval UI;
|
||||
- permission model;
|
||||
- diff preview;
|
||||
- write sandbox;
|
||||
- destructive action confirmation。
|
||||
|
||||
不能简单把:
|
||||
|
||||
```text
|
||||
read-only -> workspace-write
|
||||
```
|
||||
|
||||
当成一个配置切换。
|
||||
|
||||
### Stage 5:多 Runtime
|
||||
|
||||
保持:
|
||||
|
||||
```text
|
||||
CodexAdapter ─┐
|
||||
ClaudeAdapter ├─> ChatEvent
|
||||
PiAdapter ─┘
|
||||
```
|
||||
|
||||
这时今天设计的 application-owned contract 才真正体现价值。
|
||||
|
||||
---
|
||||
|
||||
## 17. 可迁移的架构原则
|
||||
|
||||
这套项目最值得记住的不是某个 Codex API,而是下面这些模式:
|
||||
|
||||
1. **Agent runtime 必须放在可信 server boundary 后面。**
|
||||
2. **供应商协议和产品协议要分开。**
|
||||
3. **流式 UI 应基于真实 delta,而不是字符串动画。**
|
||||
4. **delta 是实时状态,completed snapshot 是最终事实。**
|
||||
5. **Thread、Turn、Item 必须分层理解。**
|
||||
6. **Transport state 与 UI state 不应该强行共用同一模型。**
|
||||
7. **权限隔离和数据脱敏是两条不同安全防线。**
|
||||
8. **持久化必须明确数据所有者:Browser transcript 和 Agent thread 不是同一份状态。**
|
||||
9. **并发系统必须使用稳定 correlation id,而不是靠事件顺序猜归属。**
|
||||
10. **调试应该沿系统边界逐层验证。**
|
||||
|
||||
---
|
||||
|
||||
## 18. 复习检查表
|
||||
|
||||
如果可以不看代码回答下面问题,说明架构已经基本掌握:
|
||||
|
||||
- [ ] 为什么 `codex app-server` 必须运行在 server-side?
|
||||
- [ ] `thread`、`turn`、`item` 分别表示什么?
|
||||
- [ ] 为什么一个 turn 不等于一个 assistant message?
|
||||
- [ ] `item/agentMessage/delta` 在系统里经过了哪些层?
|
||||
- [ ] 为什么 `ChatEvent` 不直接复用 Codex 协议类型?
|
||||
- [ ] `ChatEvent` 和 `ChatStateEvent` 为什么要分开?
|
||||
- [ ] `assistant.completed` 已经有最终文本,为什么还需要 delta?
|
||||
- [ ] 有 delta 以后为什么仍需要 completed snapshot?
|
||||
- [ ] localStorage 和 Codex session 分别保存什么?
|
||||
- [ ] New Chat 为什么不应该等同于删除 Codex session?
|
||||
- [ ] read-only sandbox 与浏览器数据脱敏分别解决什么问题?
|
||||
- [ ] generation guard 能解决什么,不能解决什么?
|
||||
- [ ] 为什么并发 turn 必须按 `turnId` correlation?
|
||||
- [ ] process-per-turn 的优缺点是什么?
|
||||
- [ ] 什么时候应该考虑 SSE 或 WebSocket?
|
||||
- [ ] 如果未来替换成 Claude/Pi,哪些层应该变化、哪些层应该保持不变?
|
||||
|
||||
---
|
||||
|
||||
## 19. 思考题
|
||||
|
||||
1. 如果用户同时打开两个浏览器 Tab,两个 Tab resume 同一个 thread,会有哪些竞态?应该在哪一层解决?
|
||||
2. 如果 `assistant.delta` 已经追加了 200 字,但最终 `assistant.completed.text` 只有 180 字,Reducer 应该怎么处理?为什么?
|
||||
3. 如果未来允许 Agent 写文件,仅增加 `sandbox: workspace-write` 为什么不够?至少还需要哪些产品能力?
|
||||
4. 如果 app-server 变成长驻进程,一个 JSON-RPC reader 如何同时服务多个 turn?你需要哪些 registry/correlation 数据结构?
|
||||
5. 如果换成一个只提供 SSE 的 Agent provider,现有 `ChatEvent` 层还能否保留?哪些 adapter 需要变化?
|
||||
6. 为什么“浏览器永远不接触 raw runtime event”不仅是解耦设计,也是安全设计?
|
||||
7. 哪些错误应该展示给用户,哪些错误只应该进入 server log?如何给它们建立稳定 error code?
|
||||
|
||||
---
|
||||
|
||||
## 20. 最终心智模型
|
||||
|
||||
可以把整个系统压缩成一句话:
|
||||
|
||||
> **浏览器维护产品状态,TanStack Start 建立可信 RPC 边界,Application Event Contract 隔离产品与供应商协议,CodexRuntime 管理 Agent 生命周期,Codex app-server 负责真正的 thread/turn/item 执行。**
|
||||
|
||||
再进一步抽象:
|
||||
|
||||
```text
|
||||
External Agent Runtime
|
||||
↓
|
||||
Trusted Adapter
|
||||
↓
|
||||
Stable Application Events
|
||||
↓
|
||||
Deterministic Client State
|
||||
↓
|
||||
UI
|
||||
```
|
||||
|
||||
这才是这个 Demo 最有价值的架构成果。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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. Persistence:UI 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 Lifecycle:V0 与长期形态的 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/steer:long-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,不要让第三方协议定义产品状态,不要让流式体验牺牲最终一致性,也不要把“能运行”误认为“生命周期已经正确”。**
|
||||
@@ -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) | 一条消息完整经历了什么? | 把前四篇重新串成端到端模型,并提炼可迁移的工程模式 |
|
||||
|
||||
## 先修知识
|
||||
|
||||
建议先具备以下基础,再阅读效果最好:
|
||||
|
||||
- TypeScript:union type、type narrowing、async iterator、`AsyncGenerator`;
|
||||
- React:render/commit、state snapshot、`useReducer`、`useRef`、副作用边界;
|
||||
- Node.js:`child_process.spawn`、stdio、process signal;
|
||||
- Web:RPC、streaming、SSE/WebSocket 的基本区别;
|
||||
- Agent:conversation/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。
|
||||
|
||||
这些“缺口”不是文档错误,而是当前实现与下一阶段工程化之间的边界。
|
||||
@@ -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/`,并从任务记录链接过去。
|
||||
Reference in New Issue
Block a user