session: cache the derived history — one projection per node, frozen and shared

deriveMessages() no longer re-derives and re-clones the whole surface
per call: each node is projected exactly once, when first seen (O(new
nodes) per access), through the now-public deriveEventMessage — the
per-node pure function external reconstructors and the dev invariant
fold over a log prefix, so no two paths can disagree about a request's
messages. A surface rewrite (replace, invalidate) rebuilds, signalled
by SurfaceManager.replaceGeneration — monotonic, bumped by every folded
replace and by invalidate(), never reset. Callers get a fresh array
snapshot per call over SHARED deep-frozen messages: isolation of the
append-only log moves from per-call cloning to unrepresentability
(mutation throws), which the two isolation tests now pin directly.
This commit is contained in:
Tianyi Cui
2026-07-06 02:51:20 +08:00
parent a7b569850b
commit a4f7e757fa
10 changed files with 208 additions and 49 deletions

View File

@@ -209,7 +209,7 @@ A session was created in the store.
'session/created'(session: Session): void
```
Source: [`packages/core/session/src/index.ts:37`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:38`](../../packages/core/session/src/index.ts)
### `session/event` — emit
@@ -221,7 +221,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per-
Types: [SessionEvent](../core-data-structures/core.md)
Source: [`packages/core/session/src/index.ts:45`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts)
### `session/flush` — parallel
@@ -231,7 +231,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus
'session/flush'(session: Session): Promise<void> | void
```
Source: [`packages/core/session/src/index.ts:55`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts)
## `subagent/*`

View File

@@ -163,7 +163,7 @@ get(id: SessionId): Session | undefined
list(): Session[]
```
Source: [`packages/core/session/src/index.ts:328`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:345`](../../packages/core/session/src/index.ts)
## `ctx.subagents` — `SubagentService`

View File

@@ -189,9 +189,9 @@ export interface SurfaceNode {
}
```
## Derived history: `deriveMessages()`
## Derived history: `deriveMessages()` and `deriveEventMessage()`
`Session.deriveMessages()` projects the event log into the `Message[]` the model sees. The projection rules:
`Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:
- `user/message` → a user message.
- `assistant/message` → an assistant message. Raw `assistant/chunk` events are replay/UI data and are **skipped** in derivation (the assembled message is authoritative). An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to host its `usage`, but a content-less assistant turn must not enter the provider transcript.

View File

@@ -22,9 +22,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:35`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:37`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:45`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:38`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |