Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3d67eb5
feat(serve): Add cursor-paged transcript replay endpoint
doudouOUC Jul 8, 2026
5cd34a5
fix(cli): Bound transcript replay indexing
doudouOUC Jul 8, 2026
a5d5414
fix(core): Validate transcript cursors
doudouOUC Jul 8, 2026
ef05a62
fix(core): Lazy-init transcript cursor secret
doudouOUC Jul 8, 2026
d03c010
fix(serve): Address transcript replay review suggestions
doudouOUC Jul 8, 2026
c31804d
fix(serve): Stabilize transcript replay pagination
doudouOUC Jul 8, 2026
f0f77e2
fix(core): Avoid quadratic transcript line scanning
doudouOUC Jul 8, 2026
8c776a5
chore: Merge main into transcript replay branch
doudouOUC Jul 8, 2026
d39c588
fix(core): Mark transcript history gaps
doudouOUC Jul 8, 2026
72c50a1
chore: Merge main into transcript replay branch
doudouOUC Jul 8, 2026
19376bc
fix(core): Address transcript reader review comments
doudouOUC Jul 9, 2026
f0701fe
fix(serve): Address transcript replay review feedback
doudouOUC Jul 9, 2026
a1985ac
fix(serve): align transcript cursor preflight errors
doudouOUC Jul 9, 2026
5984bef
test(cli): Cover paged dangling tool call replay
doudouOUC Jul 9, 2026
6bb7780
fix(core): Bound transcript index cache bytes
doudouOUC Jul 9, 2026
02da532
fix: Address transcript replay review follow-ups
doudouOUC Jul 9, 2026
031fd19
fix(cli): Preserve pending tool calls on transcript replay errors
doudouOUC Jul 9, 2026
6354fb4
Merge remote-tracking branch 'origin/main' into agent/full-transcript…
doudouOUC Jul 9, 2026
954239d
codex: address PR review feedback (#6525)
doudouOUC Jul 9, 2026
dd09263
merge: sync main into transcript replay branch
doudouOUC Jul 9, 2026
f287b4a
codex: fix CI failure on PR #6525
doudouOUC Jul 9, 2026
1a6975d
fix(serve): warm transcript-replay tools leniently
doudouOUC Jul 9, 2026
cf8a171
merge: sync main into transcript replay branch
qwen-code-dev-bot Jul 9, 2026
8e062f8
fix(core): invalidate transcript index cache on in-place rewrites
doudouOUC Jul 9, 2026
24457b6
codex: address PR review feedback (#6525)
doudouOUC Jul 9, 2026
07a7ef7
merge: sync main into transcript replay branch
doudouOUC Jul 9, 2026
f69708c
qwen: fix CI failure on PR #6525
doudouOUC Jul 9, 2026
87b3121
qwen: address PR review feedback (#6525)
doudouOUC Jul 9, 2026
0968d5f
qwen: address PR review feedback (#6525)
doudouOUC Jul 9, 2026
61f31d8
fix(serve): address transcript review feedback
doudouOUC Jul 10, 2026
8455983
qwen: address transcript review feedback (#6525)
doudouOUC Jul 10, 2026
fc4d0a0
Merge remote-tracking branch 'origin/main' into fix/pr6525-lenient-wa…
doudouOUC Jul 10, 2026
13f8f90
qwen: adopt transcript review suggestions (#6525)
doudouOUC Jul 10, 2026
e7fbb58
chore(serve): resolve merge conflict with main in session routes
qwen-code-dev-bot Jul 10, 2026
032cce6
qwen: clean up transcript test fixtures to fix no-AK CI flake (#6525)
doudouOUC Jul 10, 2026
1ea2bcb
qwen: harden transcript reader test timestamps + assert page fields (…
doudouOUC Jul 10, 2026
7e20f1a
Merge remote-tracking branch 'origin/main' into fix/pr6525-lenient-wa…
doudouOUC Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/2026-07-07-bounded-replay-snapshot-window.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ When `replayBytes > maxReplayBytes`, the engine drops oldest segments while more
"retainedEvents": 8,
"maxBytes": 4194304,
"truncatedTurns": 3,
"fullTranscriptAvailable": false
"fullTranscriptAvailable": true
}
}
```
Expand Down
7 changes: 5 additions & 2 deletions docs/developers/daemon/08-session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,11 @@ transient signals, and produces O(turns) replay logs instead of O(tokens) logs
from that byte window, `compactedReplay[0]` is a synthetic id-less
`history_truncated` marker with `{reason: 'replay_window_exceeded',
truncatedEvents, retainedEvents, maxBytes, truncatedTurns?,
fullTranscriptAvailable: false}`. Clients should render it as status and apply
the retained replay normally; it must not trigger a resync loop.
fullTranscriptAvailable: boolean}`. `fullTranscriptAvailable` is a capability
flag: `true` means the client can page the full persisted transcript with
`GET /session/:id/transcript`, while `false` means only the bounded replay is
available. Clients should render it as status and apply the retained replay
normally; it must not trigger a resync loop.

### ACP Child Preheat

Expand Down
4 changes: 3 additions & 1 deletion docs/developers/daemon/09-event-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ Grouped by domain.
| `slow_client_warning` | Live frame backlog or live serialized-byte backlog >= 75%; force-pushed and **has no `id`** | `queueSize, maxQueued, lastEventId, queuedBytes?, maxQueuedBytes?, threshold?: 'frames' \| 'bytes' \| 'frames_and_bytes'`; re-armed after both frame and byte measurements drop below 37.5%. |
| `stream_error` | `SubscriberLimitExceededError` or another route stream error | `error: string`; terminal for the subscription. |
| `state_resync_required` | `subscribe({lastEventId})` detects that the daemon ring no longer holds `[lastEventId+1, earliestInRing-1]`, or the client cursor is from a previous bus epoch. Force-pushed **before** remaining replay frames and **has no `id`**. | `reason: 'ring_evicted' \| 'epoch_reset' \| string`, `lastDeliveredId: number`, `earliestAvailableId: number`. This is a recovery signal, not terminal: the SSE stream stays open and replay + live frames continue. The SDK reducer sets `awaitingResync = true` and skips deltas until the caller resets with `loadSession`. |
| `history_truncated` | `POST /session/:id/load` returns a bounded replay snapshot after older in-memory replay entries were dropped. Prepended to `compactedReplay` and **has no `id`**. | `reason: 'replay_window_exceeded'`, `truncatedEvents: number`, `retainedEvents: number`, `maxBytes: number`, `truncatedTurns?: number`, `fullTranscriptAvailable: false`. This is a status marker, not a resync request; clients render it and continue applying retained replay. |
| `history_truncated` | `POST /session/:id/load` returns a bounded replay snapshot after older in-memory replay entries were dropped. Prepended to `compactedReplay` and **has no `id`**. | `reason: 'replay_window_exceeded'`, `truncatedEvents: number`, `retainedEvents: number`, `maxBytes: number`, `truncatedTurns?: number`, `fullTranscriptAvailable: boolean`. This is a status marker, not a resync request; clients render it and continue applying retained replay. |
| `replay_complete` | Id-less sentinel emitted after the `Last-Event-ID` replay loop finishes, for both clean replay and ring-evicted paths, even when `data.replayedCount === 0`. **No `id`** | `replayedCount: number`; lets consumers remove catch-up UI deterministically without a timeout. |

`fullTranscriptAvailable` is a boolean capability flag, not a literal `true` type. Current daemons emit `true` when `/session/:id/transcript` can be used to page the persisted transcript; older or constrained daemons may emit `false`, and clients should keep rendering the bounded replay normally.

### Permissions (F3 + base)

| Type | Direction | Trigger | Key payload fields |
Expand Down
2 changes: 2 additions & 0 deletions docs/developers/daemon/13-sdk-daemon-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ async function resilientSubscribe(session: DaemonSessionClient) {

On reconnect the daemon replays events with `id > lastSeenEventId` from its bounded ring (default 8000 events). If the gap exceeds the ring, a `state_resync_required` frame signals the client to call `loadSession` and rebuild from the current bounded replay snapshot window. That snapshot may begin with `history_truncated`; treat it as an operator-visible status marker, not as another resync request.

`history_truncated.fullTranscriptAvailable` is a boolean capability flag. When it is `true`, callers can page the full active persisted replay with `DaemonClient.getSessionTranscriptPage(sessionId, { cursor, limit })`; when it is `false`, clients should keep rendering the bounded replay normally.

### Seeding `lastEventId` at Construction

Callers that persist the cursor across process restarts can seed it:
Expand Down
57 changes: 55 additions & 2 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design

```
['health', 'capabilities', 'session_create', 'session_scope_override',
'session_load', 'session_resume',
'session_load', 'session_resume', 'session_transcript',
'unstable_session_resume',
'session_list', 'session_prompt', 'session_cancel', 'session_events',
'slow_client_warning', 'typed_event_schema',
Expand Down Expand Up @@ -197,6 +197,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design

`session_load` and `session_resume` advertise the explicit-restore routes (`POST /session/:id/load` and `POST /session/:id/resume`). Older daemons return `404` for these paths, so SDK clients should pre-flight `caps.features` before calling. `unstable_session_resume` is still advertised as a deprecated alias for compatibility with SDKs that shipped while the underlying ACP method was named `connection.unstable_resumeSession`; new clients should gate on `session_resume`.

`session_transcript` advertises `GET /session/:id/transcript`, a read-only paged replay view over the persisted active-session JSONL. It is separate from `/load`: it does not attach a client, seed the live EventBus, create a live session, or change the live replay window. Clients should use it when they need the complete on-disk transcript for a long session, and continue using `/load` only for bounded live replay during cold UI restore.

`slow_client_warning` covers SSE backpressure behavior: (a) the daemon emits a `slow_client_warning` synthetic event-stream frame when a subscriber's live frame backlog or live serialized-byte backlog crosses 75% full, once per overflow episode (rearmed after both measurements drain below 37.5%); (b) `GET /session/:id/events` accepts a `?maxQueued=N` query param (range `[16, 2048]`) to pre-size the per-subscriber frame backlog for cold reconnects against a large replay ring. The serialized-byte cap is daemon-owned (default **2 MiB** per subscriber), live-only, and intentionally has no query parameter. The daemon-wide ring size is controlled by `--event-ring-size` (default **8000**, per #3803 §02). Old daemons silently lack the warning/query behavior — pre-flight this tag before opting in.

`typed_event_schema` advertises daemon event payloads that match the SDK's `KnownDaemonEvent` schema. Older daemons may still stream compatible frames, but SDK clients should pre-flight this tag before assuming typed event coverage.
Expand Down Expand Up @@ -504,6 +506,7 @@ Capability tags:
- `session_supported_commands` → `GET /session/:id/supported-commands`
- `session_tasks` → `GET /session/:id/tasks`
- `session_status` → `GET /session/:id/status`
- `session_transcript` → `GET /session/:id/transcript`

Common status cell:

Expand Down Expand Up @@ -1314,7 +1317,7 @@ Response:

`attached: true` means the session was already live (either from a prior `session/load`/`session/resume`, or because a coalesced concurrent caller raced just ahead).

**History replay over SSE.** While `loadSession` is in flight on the agent side, the agent may emit `session_update` notifications for persisted turns, or return bulk replay updates in the response metadata. The daemon seeds those events into the session's bounded replay snapshot window before the route response returns. For live sessions, `POST /session/:id/load` only promises that bounded window (`compactedReplay`, `liveJournal`, `lastEventId`), not the full transcript. The window is byte-capped by `--compacted-replay-max-bytes` (default 4 MiB, maximum 256 MiB); if older replay entries were dropped, `compactedReplay[0]` is an id-less `history_truncated` marker. Clients should render that marker as status and continue applying retained events. Full transcript access must use a future paginated or streaming endpoint rather than a single array response.
**History replay over SSE.** While `loadSession` is in flight on the agent side, the agent may emit `session_update` notifications for persisted turns, or return bulk replay updates in the response metadata. The daemon seeds those events into the session's bounded replay snapshot window before the route response returns. For live sessions, `POST /session/:id/load` only promises that bounded window (`compactedReplay`, `liveJournal`, `lastEventId`), not the full transcript. The window is byte-capped by `--compacted-replay-max-bytes` (default 4 MiB, maximum 256 MiB); if older replay entries were dropped, `compactedReplay[0]` is an id-less `history_truncated` marker. Clients should render that marker as status and continue applying retained events. Full persisted transcript access is exposed separately through `GET /session/:id/transcript`.

**Errors:**

Expand All @@ -1328,6 +1331,56 @@ Response:
- `409` — `session_archiving` when archive or unarchive is in flight for the same id. `Retry-After: 5`.
- `409` — `session_conflict` when the id exists in both `chats/` and `chats/archive/`; delete the session with `POST /sessions/delete` before loading.

### `GET /session/:id/transcript`

Return one page of id-less `session_update` replay frames reconstructed from the active persisted JSONL transcript. Pre-flight `caps.features.session_transcript` — older daemons return `404` for this route.

Query parameters:

| Field | Required | Notes |
| -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cursor` | no | Opaque base64url cursor returned by the previous page. Omit for the first page. The cursor is daemon-issued and tamper-checked; modifying it returns `400 invalid_transcript_cursor`. It binds to the transcript file identity and frozen first-page byte size; deleting, truncating, replacing, or archiving the file invalidates it and returns `409`. |
| `limit` | no | Number of active `ChatRecord`s to include in the page. Defaults to `100`, maximum `500`. One record can produce multiple replay frames, so `events.length` may be larger than `limit`. Invalid values return `400 invalid_transcript_limit`. |

Response:

```json
{
"v": 1,
"sessionId": "persisted-1",
"events": [
{
"v": 1,
"type": "session_update",
"data": {
"sessionUpdate": "user_message_chunk",
"content": { "type": "text", "text": "..." }
}
}
],
"nextCursor": "opaque",
"hasMore": true,
"startTime": "2026-07-08T00:00:00.000Z",
"lastUpdated": "2026-07-08T00:01:00.000Z"
}
```

`events` are replay frames only: `{ v: 1, type: "session_update", data: SessionUpdate }`. They do not carry EventBus ids, and the response never includes `lastEventId`. Calling this route does not call `/load`, attach a client, seed the live EventBus, create a live session, or change the current live replay window. Live and inactive active sessions are both reconstructed by the child-side read-only status method so replay uses the same workspace settings, runtime output directory, emitters, and `/load` history semantics without mutating daemon session state.

The first page freezes the current JSONL snapshot size. Later pages read only that byte prefix, so appends after page 1 do not change the result set. If the file disappears, is truncated below the frozen size, is replaced with a different inode, or is moved to archive, the next page returns `409` and the client should restart from page 1 or ask the user to reopen the transcript.

To protect daemon memory and latency, snapshots above the transcript indexing cap fail before the daemon scans the JSONL. Clients receive `413 transcript_too_large` and should fall back to export/offline processing or ask the user to shorten/archive older history.

`partial: true` and `replayError` may appear if replay conversion fails after producing some frames. When the response also includes `nextCursor`, clients should continue paging with that cursor; it skips records remaining after the failure point on the partial page, so the assembled replay has a hole that `partial: true` marks.

**Errors:**

- `400` — invalid `limit`, `cursor`, or session id shape.
- `404` — active persisted session id does not exist on the first page request.
- `409` — `session_archived`, `session_archiving`, or `session_conflict` from the same loadability checks as `/load`.
- `409` — transcript snapshot is unavailable because the file was deleted, truncated, replaced, or archived after the cursor was issued; this also applies when preflight can no longer find the active file for a cursor request.
- `413` — `transcript_too_large` when the frozen transcript snapshot exceeds the daemon indexing cap.

### `POST /session/:id/resume`

Restore a persisted ACP session by id WITHOUT replaying history through SSE. The model context is restored internally on the agent side (via `geminiClient.initialize` reading `config.getResumedSessionData`); the SSE stream stays clean for clients that already have history rendered. Pre-flight `caps.features.session_resume`; `unstable_session_resume` remains a deprecated compatibility alias for older clients.
Expand Down
Loading
Loading