fix(server,web): live in-progress output survives refresh and reconnect (#77)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-27 22:31:57 +08:00
committed by GitHub
parent 3d3495528c
commit 094ebd6118
18 changed files with 1214 additions and 40 deletions
+27 -4
View File
@@ -144,7 +144,7 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
| GET | / | Session info (the single-session GET additionally carries `tracePath`, the absolute path of the latest Trace file; list rows omit it) |
| PATCH | / | Update: `{approvalMode?, archived?, title?}` |
| DELETE | / | Delete the Session (along with its Traces and scratch files) |
| GET | /messages | Full OmniMessage history |
| GET | /messages | Full OmniMessage history; while a Task runs the response also carries `live` (the in-progress stream tail, see below) |
| GET | /stream | SSE event stream (next section) |
| POST | /tasks | Start a Task: `{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202. With `queueIfBusy`, a busy session holds the input as a follow-up (`queued: true`) and auto-starts it as an ordinary next task once idle; `task_state` events report the queued count |
| POST | /steer | Mid-run steering: `{text}` queues a message for the running Task (delivered between turns as a standalone `[user_steering]` user message) → 202; 409 `not_running` when no Task is in progress |
@@ -164,6 +164,28 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
General conventions: Sessions the user cannot access always return 404 — their existence is never leaked; only one Task or compaction runs per Session at a time, and conflicts return 409 (`task_in_progress` / `compacting`).
#### The `live` field on GET /messages
The Trace stores only complete messages (streaming `partial_*` never reaches disk), so history alone cannot show a message that is still streaming. While the Session is running or compacting, the messages response therefore also carries the in-progress stream tail:
```ts
interface MessagesResponse {
messages: OmniMessage[];
live?: {
// The Session channel's most recently assigned SSE event id (`<epoch>-<seq>`):
// every event published up to and including this id is already reflected in `fragments`.
cursor: string;
// One synthetic `partial_* start` OmniMessage per open streaming fragment, whose
// payload carries the full accumulated content so far (text/thinking prefix,
// tool-call name + accumulated arguments, tool-output prefix + images), with the
// original `origin` chain preserved (subagent fragments included).
fragments: OmniMessage[];
};
}
```
`cursor` and `fragments` are captured atomically before the trace read starts. A client using the connect-first pattern (below) applies them after history: when the cursor's epoch matches the epoch of the SSE events it has buffered, it drops every buffered **partial** event with seq ≤ cursor (their content is already accumulated inside `fragments`), feeds `fragments` through its normal reducer, then replays the rest of the buffer. Buffered **complete** messages are never dropped by the cursor — the regular overlap dedup decides for them. `live` is omitted while idle.
Workspace files may be Agent-generated, so `GET /files/content` treats them as untrusted: every response carries `X-Content-Type-Options: nosniff`, and the rest of the headers depend on the two flags (`download=1` wins over `preview=1`):
| Query | Content-Type | Content-Disposition | Content-Security-Policy |
@@ -255,7 +277,7 @@ export type ServerEvent =
### Delivery Guarantees
- Event ids are monotonic per channel, shaped `<epoch>-<seq>`;
- Each channel keeps a bounded replay buffer (most recent 1000 events or 2MB);
- Each channel keeps a bounded replay buffer (most recent 10,000 events or 8MB);
- Reconnecting with `Last-Event-ID` replays the gap on a buffer hit; on a miss the server first sends `resync_required`, and the client refetches `/messages` before continuing;
- A heartbeat comment line is written every 20 seconds;
- Event order: on a reconnect carrying `Last-Event-ID`, **the replayed gap (or `resync_required`) arrives first**, then the initial events — the authoritative `task_state` snapshot and still-pending approval_requests — then the live stream. A fresh connection (no `Last-Event-ID`) skips replay, so its first event is the `task_state` snapshot.
@@ -266,8 +288,9 @@ The order the bundled Web App uses:
1. Connect `/stream` first and buffer incoming events;
2. GET `/messages` for the full history;
3. Replay the buffer, deduplicating the overlap;
4. Go live.
3. If the response carries `live` (a Task is running), drop the buffered partials the cursor already covers and seed the `live.fragments` on top of history — the in-progress message reappears with its streamed prefix intact;
4. Replay the buffer, deduplicating the overlap;
5. Go live.
## Type Imports
+26 -4
View File
@@ -144,7 +144,7 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
| GET | / | Session 信息(单会话 GET 额外携带 `tracePath`:最新 Trace 文件的绝对路径;列表行不含) |
| PATCH | / | 更新:`{approvalMode?, archived?, title?}` |
| DELETE | / | 删除 Session(连同 Trace 与暂存文件) |
| GET | /messages | 完整 OmniMessage 历史 |
| GET | /messages | 完整 OmniMessage 历史;Task 运行期间响应额外携带 `live`(进行中的流式尾部,见下) |
| GET | /stream | SSE 事件流(见下节) |
| POST | /tasks | 发起 Task:`{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202。带 `queueIfBusy` 时,运行中的 Session 会把输入暂存为跟进消息(`queued: true`),空闲后按序自动作为普通 Task 发出;`task_state` 事件携带排队数 |
| POST | /steer | 运行中插话:`{text}` 为运行中的 Task 排队一条消息(作为独立的 `[user_steering]` 用户消息随下一轮送达)→ 202;无 Task 运行返回 409 `not_running` |
@@ -164,6 +164,27 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与
通用约定:无权访问的 Session 一律返回 404,不泄露其存在性;每个 Session 同时只允许一个 Task 或压缩在运行,冲突时返回 409(`task_in_progress` / `compacting`)。
#### GET /messages 的 `live` 字段
Trace 只存完整消息(流式 `partial_*` 永远不落盘),所以仅靠历史无法呈现一条正在流式输出的消息。因此当 Session 处于运行/压缩状态时,messages 响应额外携带进行中的流式尾部:
```ts
interface MessagesResponse {
messages: OmniMessage[];
live?: {
// Session 通道最近分配的 SSE 事件 id(`<epoch>-<seq>`):
// 截至该 id(含)发布的所有事件都已累积进 `fragments`。
cursor: string;
// 每个未闭合流式片段对应一条合成的 `partial_* start` OmniMessage,其 payload 携带
// 迄今累积的全部内容(文本/思考前缀、工具调用名 + 已累积参数、工具输出前缀 + 图片),
// 并保留原始 `origin` 链(子智能体片段同样覆盖)。
fragments: OmniMessage[];
};
}
```
`cursor` 与 `fragments` 在 Trace 读取开始前原子采集。使用先连接模式(见下)的客户端在应用完历史后处理它们:当 cursor 的 epoch 与本连接已缓冲事件的 epoch 一致时,丢弃 seq ≤ cursor 的已缓冲 **partial** 事件(其内容已累积在 `fragments` 里),把 `fragments` 按正常归约路径喂入,再重放剩余缓冲。已缓冲的**完整**消息从不按 cursor 丢弃 —— 仍由常规重叠去重裁决。空闲时不携带 `live`。
Workspace 文件可能由 Agent 生成,`GET /files/content` 一律按不可信内容处理:所有响应都带 `X-Content-Type-Options: nosniff`,其余响应头取决于两个开关(`download=1` 优先于 `preview=1`):
| 查询参数 | Content-Type | Content-Disposition | Content-Security-Policy |
@@ -254,7 +275,7 @@ export type ServerEvent =
### 投递保证
- 事件 id 按通道单调递增,形如 `<epoch>-<seq>`;
- 每通道维护有界重放缓冲(最近 1000 条事件或 2MB);
- 每通道维护有界重放缓冲(最近 10,000 条事件或 8MB);
- 携带 `Last-Event-ID` 重连时,命中缓冲则补发缺口;未命中则先发 `resync_required`,客户端重新拉取 `/messages` 后继续消费;
- 每 20 秒写一条心跳注释行;
- 事件次序:带 `Last-Event-ID` 重连时,**补发的缺口(或 `resync_required`)最先送达**,随后才是初始事件——权威的 `task_state` 快照与未决的 approval_request,再进入实时流;全新连接(无 `Last-Event-ID`)不重放缓冲,首个事件即为 `task_state` 快照。
@@ -265,8 +286,9 @@ export type ServerEvent =
1. 先连接 `/stream` 并缓冲收到的事件;
2. 再 GET `/messages` 拉取完整历史;
3. 回放缓冲区并对重叠消息去重;
4. 转入实时消费。
3. 若响应携带 `live`(有 Task 在运行),丢弃 cursor 已覆盖的缓冲 partial 事件,并把 `live.fragments` 播种到历史之上 —— 进行中的消息连同已流式输出的前缀一起回到画面;
4. 回放缓冲区并对重叠消息去重;
5. 转入实时消费。
## 类型导入