Core 2: Session Event Log
Official counterpart:
core/session— "the session log is the single source of truth for the model's context"
Core Idea: What the Model Sees Must Be in the Log
The biggest difference between an agent and a plain chat is process complexity: the model requests multiple times, calls tools, gets results, and continues. If we only kept the "final conversation", we could never answer:
- What exactly did the model see? (audit)
- What did the tool return at each step? (replay)
- How do we resume after an interruption? (recovery)
- How does the UI render streaming output? (fidelity)
The official answer: everything is based on an append-only event log. Anything that enters a model request must first be written as a session event; the model's message history (deriveMessages()) is projected from the log, not maintained separately.
user/message ──► assistant/message(tool_calls) ──► tool/result ──► assistant/message
└──────────────── log (ground truth) ──────────────────────┘
│
▼ deriveMessages()
the messages array the model actually seesEvent Types
| Event type | Durable | Role |
|---|---|---|
turn/start / turn/end | ✅ | Boundaries of one turn |
step/start / step/end | ✅ | Boundaries of one step (a request + its tools) |
user/message | ✅ | User input, enters the model context |
assistant/chunk | ✅ | Streaming deltas (replay/UI fidelity) |
assistant/message | ✅ | Complete assistant message (incl. tool_calls), enters the context |
tool/result | ✅ | Tool result, enters the context |
Why keep assistant/chunk?
assistant/message is the final result; chunk is the process. Keeping chunks in the log lets the UI replay exactly how the text was generated. The official calls this "preserving replay and UI fidelity".
Implementation
Append-only log
/**
* 会话事件日志:append-only SessionEvent + 消息投影。
*
* 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
* 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
export interface SessionEvent {
type: string;
seq: number;
ts: number;
[key: string]: unknown;
}
export interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
export class Session {
id: string;
events: SessionEvent[] = [];
constructor(id: string = randomUUID().slice(0, 12)) {
this.id = id;
}
/** 追加一条会话事件,自动带上序号与时间戳 */
append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
this.events.push(event);
return event;
}
/** 从日志投影模型可见的历史消息(官方 deriveMessages) */
deriveMessages(): ChatMessage[] {
const messages: ChatMessage[] = [];
for (const ev of this.events) {
if (!MESSAGE_TYPES.has(ev.type)) continue;
if (ev.type === "user/message") {
messages.push({ role: "user", content: ev.content as string });
} else if (ev.type === "assistant/message") {
const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
messages.push(msg);
} else if (ev.type === "tool/result") {
messages.push({
role: "tool",
tool_call_id: ev.tool_call_id as string,
content: ev.content as string,
});
}
}
return messages;
}
// ---------- 持久化 ----------
save(path: string): void {
mkdirSync(dirname(path), { recursive: true });
const lines = [
JSON.stringify({ type: "session/meta", id: this.id }),
...this.events.map((ev) => JSON.stringify(ev)),
];
writeFileSync(path, lines.join("\n") + "\n", "utf8");
}
static load(path: string): Session {
const session = new Session();
const text = readFileSync(path, "utf8");
for (const line of text.split("\n")) {
if (!line.trim()) continue;
const ev = JSON.parse(line);
if (ev.type === "session/meta") {
session.id = ev.id as string;
continue;
}
session.events.push(ev);
}
return session;
}
}
/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
private map = new Map<string, Session>();
create(): Session {
const s = new Session();
this.map.set(s.id, s);
return s;
}
get(id: string): Session | undefined {
return this.map.get(id);
}
all(): Session[] {
return [...this.map.values()];
}
}Key points:
append()addsseqandtsautomatically; events are immutable — that's "append-only"deriveMessages()cares about only the three message events and projects them into an array the model can consume directly
Message projection
/**
* 会话事件日志:append-only SessionEvent + 消息投影。
*
* 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
* 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
export interface SessionEvent {
type: string;
seq: number;
ts: number;
[key: string]: unknown;
}
export interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
export class Session {
id: string;
events: SessionEvent[] = [];
constructor(id: string = randomUUID().slice(0, 12)) {
this.id = id;
}
/** 追加一条会话事件,自动带上序号与时间戳 */
append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
this.events.push(event);
return event;
}
/** 从日志投影模型可见的历史消息(官方 deriveMessages) */
deriveMessages(): ChatMessage[] {
const messages: ChatMessage[] = [];
for (const ev of this.events) {
if (!MESSAGE_TYPES.has(ev.type)) continue;
if (ev.type === "user/message") {
messages.push({ role: "user", content: ev.content as string });
} else if (ev.type === "assistant/message") {
const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
messages.push(msg);
} else if (ev.type === "tool/result") {
messages.push({
role: "tool",
tool_call_id: ev.tool_call_id as string,
content: ev.content as string,
});
}
}
return messages;
}
// ---------- 持久化 ----------
save(path: string): void {
mkdirSync(dirname(path), { recursive: true });
const lines = [
JSON.stringify({ type: "session/meta", id: this.id }),
...this.events.map((ev) => JSON.stringify(ev)),
];
writeFileSync(path, lines.join("\n") + "\n", "utf8");
}
static load(path: string): Session {
const session = new Session();
const text = readFileSync(path, "utf8");
for (const line of text.split("\n")) {
if (!line.trim()) continue;
const ev = JSON.parse(line);
if (ev.type === "session/meta") {
session.id = ev.id as string;
continue;
}
session.events.push(ev);
}
return session;
}
}
/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
private map = new Map<string, Session>();
create(): Session {
const s = new Session();
this.map.set(s.id, s);
return s;
}
get(id: string): Session | undefined {
return this.map.get(id);
}
all(): Session[] {
return [...this.map.values()];
}
}Note the assistant/message projection: include tool_calls when present; tool/result pairs with the assistant's tool call via tool_call_id — a hard requirement of the OpenAI-compatible protocol, order must not change:
// what the model sees (strict order)
[
{ role: "user", content: "What is 1+1?" },
{ role: "assistant", content: "", tool_calls: [{ id: "call_1", function: { name: "run_bash", arguments: "..." } }] },
{ role: "tool", tool_call_id: "call_1", content: "2" },
{ role: "assistant", content: "The answer is 2." },
]Persistence: JSONL
/**
* 会话事件日志:append-only SessionEvent + 消息投影。
*
* 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
* 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
export interface SessionEvent {
type: string;
seq: number;
ts: number;
[key: string]: unknown;
}
export interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
export class Session {
id: string;
events: SessionEvent[] = [];
constructor(id: string = randomUUID().slice(0, 12)) {
this.id = id;
}
/** 追加一条会话事件,自动带上序号与时间戳 */
append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
this.events.push(event);
return event;
}
/** 从日志投影模型可见的历史消息(官方 deriveMessages) */
deriveMessages(): ChatMessage[] {
const messages: ChatMessage[] = [];
for (const ev of this.events) {
if (!MESSAGE_TYPES.has(ev.type)) continue;
if (ev.type === "user/message") {
messages.push({ role: "user", content: ev.content as string });
} else if (ev.type === "assistant/message") {
const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
messages.push(msg);
} else if (ev.type === "tool/result") {
messages.push({
role: "tool",
tool_call_id: ev.tool_call_id as string,
content: ev.content as string,
});
}
}
return messages;
}
// ---------- 持久化 ----------
save(path: string): void {
mkdirSync(dirname(path), { recursive: true });
const lines = [
JSON.stringify({ type: "session/meta", id: this.id }),
...this.events.map((ev) => JSON.stringify(ev)),
];
writeFileSync(path, lines.join("\n") + "\n", "utf8");
}
static load(path: string): Session {
const session = new Session();
const text = readFileSync(path, "utf8");
for (const line of text.split("\n")) {
if (!line.trim()) continue;
const ev = JSON.parse(line);
if (ev.type === "session/meta") {
session.id = ev.id as string;
continue;
}
session.events.push(ev);
}
return session;
}
}
/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
private map = new Map<string, Session>();
create(): Session {
const s = new Session();
this.map.set(s.id, s);
return s;
}
get(id: string): Session | undefined {
return this.map.get(id);
}
all(): Session[] {
return [...this.map.values()];
}
}One JSON event per line, session metadata (id) on the first line. An append-only log fits JSONL naturally: resume = read line by line, persist = append.
Session registry
/**
* 会话事件日志:append-only SessionEvent + 消息投影。
*
* 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
* 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
*/
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
export interface SessionEvent {
type: string;
seq: number;
ts: number;
[key: string]: unknown;
}
export interface ToolCall {
id: string;
type: "function";
function: { name: string; arguments: string };
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: ToolCall[];
tool_call_id?: string;
}
const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);
export class Session {
id: string;
events: SessionEvent[] = [];
constructor(id: string = randomUUID().slice(0, 12)) {
this.id = id;
}
/** 追加一条会话事件,自动带上序号与时间戳 */
append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
this.events.push(event);
return event;
}
/** 从日志投影模型可见的历史消息(官方 deriveMessages) */
deriveMessages(): ChatMessage[] {
const messages: ChatMessage[] = [];
for (const ev of this.events) {
if (!MESSAGE_TYPES.has(ev.type)) continue;
if (ev.type === "user/message") {
messages.push({ role: "user", content: ev.content as string });
} else if (ev.type === "assistant/message") {
const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
messages.push(msg);
} else if (ev.type === "tool/result") {
messages.push({
role: "tool",
tool_call_id: ev.tool_call_id as string,
content: ev.content as string,
});
}
}
return messages;
}
// ---------- 持久化 ----------
save(path: string): void {
mkdirSync(dirname(path), { recursive: true });
const lines = [
JSON.stringify({ type: "session/meta", id: this.id }),
...this.events.map((ev) => JSON.stringify(ev)),
];
writeFileSync(path, lines.join("\n") + "\n", "utf8");
}
static load(path: string): Session {
const session = new Session();
const text = readFileSync(path, "utf8");
for (const line of text.split("\n")) {
if (!line.trim()) continue;
const ev = JSON.parse(line);
if (ev.type === "session/meta") {
session.id = ev.id as string;
continue;
}
session.events.push(ev);
}
return session;
}
}
/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
private map = new Map<string, Session>();
create(): Session {
const s = new Session();
this.map.set(s.id, s);
return s;
}
get(id: string): Session | undefined {
return this.map.get(id);
}
all(): Session[] {
return [...this.map.values()];
}
}Corresponds to the official ctx.sessions — manages multiple sessions (multi-session web UI, resume-after-interrupt).
A Real Session Log
This is the actual output of pnpm run demo (scripted demo):
turn/start {"agent":"5a1b896f-beb"}
user/message {"content":"1+1 等于多少?用工具算一下"}
step/start {"step":0}
assistant/chunk {"delta":{"content":"让我先算"}}
assistant/chunk {"delta":{"content":"一下。"}}
assistant/message {"content":"让我先算一下。","tool_calls":[{"id":"call_1",...}]}
tool/result {"tool_call_id":"call_1","content":"2"}
step/end {"step":0,"tool_calls":1}
step/start {"step":1}
assistant/chunk {"delta":{"content":"计算完成"}}
assistant/chunk {"delta":{"content":":1+1"}}
assistant/chunk {"delta":{"content":"=2。"}}
assistant/message {"content":"计算完成:1+1=2。","tool_calls":[]}
step/end {"step":1,"tool_calls":0}
turn/end {}The "2" in tool/result is not fabricated — it's the real result of run_bash executing echo 1+1 | bc.
An engineering iron rule
Any new input entering the model context (e.g. skill injection in a later chapter) must first go into the log, then into the request. Bypassing the log corrupts replay and audit. The official enforces this with runtime invariants; we rely on discipline.
Test Coverage
it("deriveMessages correctly projects model history", () => {
// ...build the log...
expect(messages).toEqual([...]); // assert order and fields one by one
});
it("non-message events (step/start etc.) never enter the model context", () => {
// turn/start, step/start, assistant/chunk are all filtered
expect(roles).toEqual(["user", "assistant"]);
});Recap
- The log is the single source of truth; context is projected from it
- Message events
user/message/assistant/message/tool/resultenter the model; everything else serves replay and audit - JSONL persistence makes sessions resumable and replayable
Next: LLM Adapter Seam →