If you've been writing Agents for a while, you've surely run into this confusion: the model clearly "remembers" the tool call results from the previous turn on the second turn, yet when you go digging through the database, you can't find any table storing "conversation history." The answer DeepSeek Harness gives is deeply counterintuitive—conversation history isn't stored separately at all; it's merely a projection derived from an append-only event log. In this article, we'll crack open the observability internals of DeepSeek Harness (hereafter abbreviated as dsh): how a Session becomes the single source of truth, how turns and steps carve out execution boundaries on the log, and why trajectory replay only requires "re-deriving the same set of events once." This is deep-water material aimed at advanced Agent developers; by the end, you'll understand why dsh dares to claim that "replay isn't re-running, it's re-projecting," and how this design turns observability from "logging after the fact" into an "architectural invariant." This is part 1/2 of the full article; we'll first lay a solid foundation across the three pillars of logs, projections, and event domains, then move on to replay engineering and hands-on debugging in part 2.

Session as an append-only log: the trio of SessionEvent, monotonic seq, and epoch-millisecond time

Let's start with a question: where exactly is that conversation history the model sees actually stored? Most frameworks answer "in an in-memory messages array, casually persisted to disk." dsh's answer is entirely different—it isn't stored in any dedicated place; it's derived from a session log. This sentence is the master key to understanding the entire design.

In dsh, a Session is essentially an append-only log composed of typed SessionEvents. Note the weight of the words "append-only": once a line is written to the log, it is never modified, deleted, or reordered. You cannot "update a historical message"; you can only append a new event to express "something changed here." This is the same philosophy as Git's commit chain or Kafka's partition log—trading immutable append operations for full traceability and deterministic replay.

Every event in the log carries a stable "trio":

  • type: the event's type tag, such as turn/start, assistant/message, tool/result. It determines which semantic domain the event belongs to, and it's also the key to the discriminated-union narrowing done later.
  • seq: a monotonically increasing sequence number, assigned in a very geeky way—seq = log.length. That is, once an event is appended, its seq is its index position in the log. The first event has seq 0, the second has 1, and so on, with no gaps ever appearing in between. This yields an extremely strong guarantee: seq is the event's absolute coordinate in history. The ordering relationship between any two events can be compared directly by the magnitude of their seq, with no reliance on timestamps.
  • time: a timestamp in epoch milliseconds. For example, 1755000000000 in the sample log is a Unix millisecond time, used for human reading, for sorted display, and for performance analysis (such as measuring the duration of a step). But note that time does not bear the responsibility of determining causal order—causal order is guaranteed by seq, while time only serves as a "wall-clock" reference. This is a mature engineering trade-off: timestamps can become ambiguous due to clock skew or precision issues, whereas seq cannot.

Why design the "trio" this way? Because they each answer three orthogonal questions: type answers "what is this", seq answers "where exactly does it sit in history", time answers "roughly when did it happen". Splitting these three dimensions apart avoids overloading a single field with too many responsibilities, and gives replay, debugging, and resumable streaming each a clean handle of their own.

In terms of status, this log is the single source of truth for the agent's complete interaction history. This is not a marketing slogan, but a constraint that gets repeatedly validated at the architectural level: the LLM message history is derived from the log and never stored separately; replay is simply re-deriving history from the same set of events. In other words, the log is the source, and messages are a view. The conversation you see in the UI, the messages array you send over the API, and every step reproduced in the replayer are all, in essence, different projections of the same log.

There is also an easily overlooked detail: every event in the log must have its data losslessly serializable to JSON, and this constraint is enforced at the source by Session.append. Why clamp down at the append layer? Because the log is meant to be persisted, transmitted across processes, and loaded back for replay. Once you allow things like Map, Date, class instances, or circular references to be stuffed in, replay has to face the disaster of "semantics changed after deserialization." Moving validation forward to the write entry point costs the least and pays off the most—be strict on write, so you can be free on read.

The table below lays out the responsibilities of the trio side by side; it is recommended as a reference when implementing your own Agent event system:

FieldType/ValueCore ResponsibilityOptional?Engineering Pitfalls
typeString literal (discriminant of a discriminated union)Identifies event semantics, determines projection and routingRequiredAdding a new type must be accompanied by extending SessionEventMap, otherwise narrowing breaks
seqInteger, equal to log.length before appendProvides the absolute coordinate and causal order in historyRequiredDo not use timestamps instead of seq for ordering; clock rollback will destroy the order
timeEpoch milliseconds integerWall-clock time reference, used for display and duration statisticsRequiredDo not use it for business causality judgments; it is not comparable across machines
dataObject losslessly JSON-serializableCarries the event payload, the raw material for projectionRequiredStuffing in Date/Map will distort on replay; the append stage should reject it outright

Once you fully grasp this trio, you'll realize that dsh's session log is actually an event stream with its own coordinate system, referable timestamps, and identifiable types. It neither loses structure the way plain-text logs do, nor introduces state mutability the way database tables do. This is precisely what gives it the confidence to serve as the "single source of truth."

Why LLM message history isn't stored separately: log-derived + replayable transcript as the SDK consumption pattern

In traditional approaches, many Agent frameworks maintain a separate messages[] array and then store that array alongside the raw run log as two separate copies. The problem follows immediately: the two copies drift apart. You fix a bug in the concatenation logic—the log records the old behavior, while the messages array uses the new logic. You want to trace back "what the model actually saw at the time," only to find that the log records "what we thought it saw." This kind of "dual-write inconsistency" is the greatest enemy of observability.

dsh's approach eliminates the second write path at the root: LLM message history isn't stored separately—it's derived from the log. "Derive" here means there's a well-defined, deterministic projection rule that maps the event log into Message[]. Since the rule is deterministic, the same set of events will always derive an identical message history at any time, on any machine—this is the mathematical foundation of "replayability."

There's a crucial semantic point here: replay = re-deriving history from the same set of events. It's not re-running the model, not replaying network requests, but feeding the log back into the projection function. This means trajectory replay is purely functional: input is a sequence of events, output is a sequence of messages—no side effects, no randomness, no dependence on external state. This enables many things that traditional frameworks can't achieve:

  • Deterministic debugging: When an error occurs, you slice out that segment of the log, and anyone who replays it sees the same messages—no more "it doesn't reproduce on my machine."
  • Zero-cost forking: Want to try different projection logic at a certain step (say, reformatting tool results)? Just apply a new projector to the same log—not a single piece of historical data needs to change.
  • Coexisting multiple views: The same log can derive "messages for the model," "transcript for reviewers," and "timeline for the UI"—they don't interfere with each other because they're all just projections.

So as an SDK user, if I want to obtain "replayable transcript data," how should I consume it? The official guidance is clear: SDK users who need replayable transcript data should consume the session/event event stream. Note that it's not telling you to read some snapshot file, nor to call deriveMessages to get the final result, but rather to subscribe to the event stream itself. The reason is simple: only the event stream retains all the original information—including those boundary events and streaming chunks that don't project into messages. Once you only take the derived messages, you lose the turn/step boundaries, the token-level fidelity of assistant/chunk, and the raw parameters of tool/call. To do a complete replay, you need to hold onto the source event stream.

Here is an example you can paste and run directly: use Node.js to consume a session log event stream in JSONL format, capturing both boundary events and derived projections. This uses the standard JSONL packed-line layout—each line is a complete event object.

// File path: examples/consume_session_events.mjs
// Consume a session/event event stream (JSONL line layout), demonstrating
// 1) how to process events one by one in a streaming fashion; 2) how to simultaneously obtain derived messages and the boundary data needed for replay.
// Run: node examples/consume_session_events.mjs examples/session.jsonl

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

/**
 * Consume an event stream: this is the SDK-recommended "replayable transcript" approach.
 * Note: we retain all events, rather than only the derived messages.
 * @param {string} path JSONL session log path
 */
async function consumeSessionStream(path) {
  const rl = createInterface({
    input: createReadStream(path, { encoding: "utf-8" }),
    crlfDelay: Infinity,
  });

  const events = [];      // Raw events, the material for replay and debugging
  const boundaries = [];  // turn/step boundaries, not projected as messages but required for replay
  const messages = [];    // Derived model-visible history

  for await (const line of rl) {
    if (!line.trim()) continue;
    const ev = JSON.parse(line);
    events.push(ev);

    // Boundary events: record execution structure, but do not enter messages
    if (ev.type === "turn/start" || ev.type === "turn/end") {
      boundaries.push({ kind: ev.type, turn: ev.data.turn, seq: ev.seq });
    } else if (ev.type === "step/start" || ev.type === "step/end") {
      boundaries.push({ kind: ev.type, turn: ev.data.turn, step: ev.data.step, seq: ev.seq });
    }

    // surface events: produce model-visible messages according to projection rules
    const d = ev.data;
    if (ev.type === "user/message") {
      messages.push({ role: "user", content: d.content });
    } else if (ev.type === "assistant/message") {
      // Empty-content assistant/message does not enter the provider transcript
      const content = d.message.content;
      if (!Array.isArray(content) || content.length > 0) {
        messages.push({ role: "assistant", content });
      }
    } else if (ev.type === "tool/result") {
      messages.push({
        role: "user", // Tool results return to the model in the user role
        content: [{ type: "tool-result", toolName: d.message.toolName, content: d.message.content }],
      });
    }
    // assistant/chunk, tool/call, turn/*, step/* are not projected as messages
  }

  console.log("Total events:", events.length);
  console.log("Boundary events:", boundaries.length);
  console.log("Derived messages:", messages.length);
  console.log("Last event seq:", events.at(-1)?.seq, "time:", events.at(-1)?.time);
  return { events, boundaries, messages };
}

await consumeSessionStream(process.argv[2]);

There are two design points worth highlighting in this code. First, the events array is the foundation of replay: it preserves seq and time, so you can re-project any segment. Second, boundaries are collected separately, because turn/step boundaries "are not projected as messages but are essential for replay"—if you want to reconstruct, in the replayer, "at which step the model received the tool result," you must retain these boundary events.

switch (event.type) narrows directly: discriminated unions and the lossless JSON serialization constraint on event.data

Whether events are "typed" makes a huge difference. Events in dsh form a true discriminated union based on type, which means that when you write switch (event.type) in TypeScript, the compiler can narrow the type of event.data directly, and you don't need to write any as type assertions.

Here's an intuitive example. When you write

switch (event.type) {
  case "step/start":
    // here event.data is narrowed to { turn: number; step: number }
    console.log(event.data.turn, event.data.step);
    break;
  case "tool/call":
    // here event.data is narrowed to { turn; step; callId; name; arguments }
    // arguments is the raw JSON string produced by the model, not a parsed object
    console.log(event.data.name, event.data.arguments);
    break;
  case "tool/result":
    // here event.data is narrowed to { turn; step; message; error?; meta? }
    console.log(event.data.message.toolName, event.data.error ?? "ok");
    break;
}

Inside the case branches, accessing event.data.turn and event.data.arguments directly is type-safe, the IDE auto-completes them, and a misspelled field name immediately shows up in red. This isn't a "documentation convention" but a contract enforced by the type system. Why is this possible? Because the data shape of each event type is precisely mapped by SessionEventMap, and the type field is the discriminant of this union.

Here's a common misconception to correct: "being able to narrow" does not equal "runtime safety". Type assertions solve a compile-time problem, but logs can be hand-written, tampered with, or injected by external systems. So there's an even lower line of defense: all event.data must be losslessly serializable to JSON, and Session.append enforces this at the source. By "lossless," we mean that after JSON.parse(JSON.stringify(data)), the semantics remain unchanged. What kinds of things break losslessness?

  • Date objects: after serialization they become strings, and deserializing gives back a string, not a Date—type drift.
  • Map / Set: JSON.stringify turns them directly into {}, silently losing data—the most dangerous case.
  • Class instances: methods are lost, leaving only enumerable own properties, and the prototype chain is broken.
  • undefined and functions: these values don't exist in JSON and are silently dropped or turned into null.
  • Circular references: JSON.stringify throws immediately.
  • NaN / Infinity: serialized to null, causing semantic pollution.
  • BigInt: JSON.stringify throws; it cannot be serialized.

Placing this constraint at the append entry point is a classic "enforce the strictest validation at the system boundary" approach. When writing, it's better to throw than to let an event that can't be replayed slip into the log—because one bad event pollutes the replay capability of the entire timeline. This combination can be summed up in one sentence: the discriminated union guarantees type correctness on the write side, the lossless JSON constraint guarantees semantic preservation on the storage side, and only together do they let the read side (derivation and replay) confidently assume that "every entry in the log is reconstructable."

One more practical tip: if you genuinely need to carry complex structures in your business logic (say, a Date), the standard approach is to explicitly convert them into a JSON-compatible form before writing—for example, storing epoch milliseconds instead of a Date object—and then restoring them as needed during the projection phase. This is entirely consistent with the philosophy behind dsh storing epoch milliseconds in time rather than storing a time object.

The "model-visible means recorded" invariant: a new model-visible input = a new session event

This is the most hardcore and most easily overlooked rule in the entire design: model-visible means recorded. Spelled out, it means—everything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts exactly this.

What is an "invariant"? It is an assertion that must hold true at any moment and in any state. The assertion here is: everything that enters a model request can be traced back to a corresponding event in the log. If one day you add a piece of code that "quietly slips a system prompt to the model" without adding a new session event for it, this invariant is broken—the runtime assertion will fail immediately, rather than letting you discover only on the next replay that "what the model saw at the time doesn't match what's recorded in the log."

The corollary of this invariant is extremely important, so be sure to remember it: adding a new model-visible input requires adding a new session event. The concrete steps are twofold:

  1. Extend SessionEventMap: define a new event type and its data shape for this new kind of input, making it a member of the discriminated union.
  2. Render from the log: make deriveMessages' projection rules know how to handle this new event, so that it appears in the derived history.

Why must both steps be done? If you only do the first, the event is recorded but not projected, so the model can't see it—recording and view are disconnected; if you only do the second, a chunk of content appears out of thin air in the projection with no corresponding event in the log—that content is lost on replay, violating the invariant. Only both steps together satisfy the definition that "the log can reconstruct what the model sees."

This design yields several very practical benefits:

  • Auditable: every word the model sees has a provenance (traceable to a specific seq). When hallucination or privilege escalation occurs, you can pinpoint exactly which event caused the input.
  • Replayable: because "visible means reconstructible," given the log you can 100% reproduce the model's input without any extra side-channel records.
  • Evolvable: want to add a new input? First add the event type, then change the projection. The direction of change is forced to be "log-first," architecturally preventing dual-write inconsistency.

Conversely, this also provides an excellent checklist for code review. When you review a PR that "adds something to the model," you only need to ask three questions: Is SessionEventMap extended? Are the projection rules changed? Will the invariant assertion pass? Only when all three are yes is the change architecturally self-consistent. This approach of hardening the spec into a runtime invariant is the watershed that distinguishes dsh's observability from ordinary "logging" frameworks—it turns "should be recorded" from a matter of human diligence into a system-enforced requirement.

A line-by-line breakdown of the deriveMessages() projection table: who gets in and who doesn't for user/message, assistant/message, and tool/result

Now that you understand "the log is the source of truth, messages are the projection," the most critical next step is to figure out the projection rules. The function in dsh responsible for this is Session.deriveMessages(), which projects the event log into the Message[] that the model sees. The rules are actually quite plain, but every line has its rationale.

First, let's clarify a concept: only surface events are projected into messages. A surface refers to an event that "constitutes the surface content of a conversation." In dsh there are three kinds of surface events, and they carry a marker field surfaceOp that describes how they join the derived surface. Let's break down the projection table line by line:

Event TypeProjected AsKey NotesHas surfaceOp
user/messageA user messageCarries the exact content; the optional envelope serves only as log display metadataYes
assistant/messageAn assistant messageIncludes provider, model, and optional replay stateYes
assistant/chunkSkippedBelongs to replay/UI data; the assembled message is authoritativeNo
tool/resultA user message with a tool-result blockTool results return to the model under the user roleYes
turn/*, step/*SkippedStructural information, not projected into messagesNo

Reading each line in detail:

  1. user/message → a user message. It carries the exact content. There's a subtlety here: a user/message may have an optional envelope, but the envelope serves only as log display metadata and does not enter the model-visible content. In other words, the display layer can attach envelope information such as "source channel" or "confidence" to a message, but what the model sees is still the clean content. This distinction is quite practical—it decouples "metadata for auditing" from "the body text for the model."
  2. assistant/message → an assistant message. Includes provider, model, and optional replay state. This additional information allows replay to reconstruct "which provider's which model produced this message," making it valuable material for multi-model trajectory comparison.
  3. assistant/chunk → skipped. Why skip it? Because it is a raw streaming fragment belonging to replay/UI data, and the assembled message is authoritative. This point is extremely important: what the model sees, and what is truly authoritative, is the assembled assistant/message; chunks are merely raw material for token-level replay fidelity. If chunks were also counted during projection, it would result in duplication. The logs in the material illustrate this relationship—assistant/message (seq 5) precisely points to the two chunks that compose it (seq 3, 4) via sourceEventSeqs: [3, 4].
  4. tool/result → a user message with a tool-result block. Note that the role is user, not tool. This is a common convention in LLM message protocols: tool execution results are fed back to the model under the user role. dsh follows this. The tool/result message in the material carries role: "tool", toolName: "bash", content: "runoob", isError: false, and after projection becomes a tool-result block within a user message.
  5. turn/*, step/* → skipped. They are structural information used to delineate execution boundaries and are not projected into messages. This explains why boundaries had to be collected separately when consuming the event stream earlier—they matter for replay, but not for "what the model sees."

There's one more detail that lies outside the projection rule table but is just as critical: tool/call is also not projected as a message. You'll notice that the tool/call (seq 6) in the source material indeed does not appear in the derived history. Why? Because the tool invocation initiated by the model has its semantics already carried by the tool_use block inside assistant/message (see the assistant/message at seq 5, whose content already contains type: "tool_use", id: "call_1", name: "bash"). The tool/call event is execution-side bookkeeping, used to associate the callId with the subsequent result, rather than re-entering the model's view. This distinction makes "what the model said it would call" and "what the system actually called" two comparable threads of evidence.

Layer on another rule planted earlier: an assistant/message with empty content is also skipped. So the complete skip list is: assistant/chunk, turn/*, step/*, tool/call, plus "assistant/message with empty content." The runnable Python example below weaves the projection rules and boundary printing together, making it easy for you to follow along against the source log:

# File path: examples/derive_and_boundaries.py
# Parse a JSONL session log: print execution boundaries in one pass, rebuild the model-visible history in another.
# This is a simplified teaching model of Session.deriveMessages(); the real implementation is cached and returns frozen messages.
import json
import sys


def derive_messages(events):
    """Project only surface events, simulating the projection rules of deriveMessages.

    - user/message      -> user message
    - assistant/message -> assistant message (empty content skipped)
    - tool/result       -> user message carrying a tool-result block
    - assistant/chunk / tool/call / turn/* / step/* are not projected
    """
    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            content = d["message"]["content"]
            if not content:          # empty content does not enter the provider transcript
                continue
            messages.append({"role": "assistant", "content": content})
        elif t == "tool/result":
            messages.append({
                "role": "tool",
                "name": d["message"]["toolName"],
                "content": d["message"]["content"],
            })
    return messages


def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # First pass: print execution boundaries to understand the nesting of turn and step.
    for ev in events:
        d = ev["data"]
        t = ev["type"]
        if t == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif t == "turn/end":
            print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
        elif t == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif t == "step/end":
            print(f"  [step/end] turn={d['turn']} step={d['step']}")
        elif t == "assistant/chunk":
            print(f"  chunk: {d['chunk']['type']}")
        elif t == "tool/call":
            print(f"  tool/call: {d['name']} args={d['arguments']}")

    # Second pass: rebuild the model-visible derived history.
    print("\nModel-visible derived messages:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")


if __name__ == "__main__":
    main(sys.argv[1])

Feed that log excerpt from the material into this script, and you'll see that in the projected history, the chunk's text-delta and tool-call-delta are both gone, and tool/call is gone too—only three things remain: user messages, assistant messages, and tool results. This is a direct manifestation of "the assembled messages are the authority."

Caching and Freezing: A surface node is projected once when it first appears, rebuilt on rewrite, and history is type-level immutable

Now that the projection logic is clear, there's still an engineering problem that can't be avoided: performance and safety. Logs grow longer and longer. If every time someone asks "what does the model see," you re-project all events from scratch, then in a session with thousands of events, every history retrieval is O(n). How does dsh's deriveMessages handle this? The answer lies in two sentences: it is cached; it returns a frozen message array.

First, caching. Each surface node is projected once when it first appears, and rebuilt when the surface is rewritten. How should we understand this? The derived history is essentially an ordered sequence composed of a series of surface nodes (the user/message, assistant/message, and tool/result categories). When you first need the derived result, the system traverses the log, projects these surface nodes one by one into messages, and caches the results. On subsequent calls to deriveMessages, it hits the cache directly without recomputation.

So what does "surface rewrite" mean? Since the log is append-only, shouldn't history be immutable? The key is—the log is immutable, but the surface is a view that can be "rewritten" by subsequent events. For example, in certain scenarios, a later event may express that "some property of a previous message has been corrected/supplemented." In this case, the cache no longer performs a full recomputation, but instead rebuilds only the affected parts. This reduces the cost of incremental updates from O(n) to close to O(amount of change). This is a typical three-layer architecture of "immutable log + mutable view + cache": the immutable bottom layer guarantees a reliable source of truth, the middle view layer allows evolution, and the cache layer ensures performance.

Now let's look at "freezing." What deriveMessages returns is a frozen message array. This means the caller cannot modify it after receiving it—no push, no changing the fields of an element. Why do this? Because "modifying recorded history through projection is not expressible at the type level." This sentence is very elegant and worth chewing over repeatedly:

  • The derived history is a projection of the log; its authority comes from the log, not from this array itself.
  • If you could modify this array, then "the history the model sees" and "the history recorded in the log" would diverge, and the status of the single source of truth would instantly collapse.
  • So the design directly prohibits modification at the type level—the array is frozen, write operations throw at runtime (in strict mode), and the type does not expose a mutable interface.

The point of this constraint is to turn "things you shouldn't do" into "things you can't do." Rather than writing "please don't modify derived history" ten times in the docs, you make it outright impossible at the type and runtime level. This is the overall character of dsh's observability design: guaranteeing correctness through mechanisms rather than relying on human self-discipline. For anyone building an Agent framework, this is a pattern worth borrowing directly—immutable source of truth, cached projection, frozen output—and with all three in place, it's both fast and stable.

The dual identity of empty-content assistant/message: records usage and provider model, yet never enters the provider transcript

One edge case especially showcases the care in the design: an assistant/message with empty content. It both exists and "doesn't exist," and this dual identity deserves to be called out on its own.

First, the side where it "exists." The assistant/message event records every successful provider call, including calls that return empty content or end with max-tokens. In other words, when the model is truncated because it hits the max-tokens limit and returns an empty-content response, dsh still records an assistant/message event. Why? Because usage, provider, and model information must be preserved. The assistant/message in the source material carries usage: { inputTokens: 12, outputTokens: 4 }, and this billing and quota information must be persisted—otherwise you can't do cost accounting or token budgeting. Even if the content is empty, the "account" for this call cannot be lost.

Now the side where it "doesn't exist": an assistant turn with no content must not enter the provider transcript. That is, when deriving message history, this empty-content assistant/message is skipped and will not appear as an empty assistant message in the next model request. Why? Because most provider APIs either don't accept empty-content assistant messages or behave inconsistently toward them; stuffing one in wastes tokens at best and throws errors at worst. More importantly, it's about semantic correctness—a turn that was truncated with no output should never be treated by the model as "I said something empty."

This in-and-out trade-off perfectly demonstrates dsh's core distinction: "recorded in the log" and "entering the model view" are two different things. The log is responsible for completeness and auditability (usage must be kept), while the projection is responsible for the model contract and cleanliness (empty messages must be filtered out). The summary in the source material is extremely precise: "Empty content does not enter derived history, but the persisted event still retains usage."

There's one more related detail: this assistant/message precisely lists its corresponding assistant/chunk events via sourceEventSeqs, including an explicit empty list. The phrase "explicit empty list" is key—it means that even if an assistant/message has no chunk backing it (for example, a pre-assembled message was returned directly), the system still writes an empty array to express "there really are no corresponding chunks," rather than omitting the field. Why insist on being explicit? Because "none" and "field missing" must be distinguished during replay: omitting the field makes the reader wonder "was the data lost?", whereas an explicit empty array clearly states "there really were no raw fragments this time." This is yet another example of the strict distinction between "unknown" and "empty" in observability design.

Here is a comparison checklist summarizing the rules for this part:

ScenarioWhether written to assistant/message eventWhether included in derived historyWhether usage is retained
Normal return with contentYesYesYes
max-tokens truncation with no contentYesNoYes
Provider returns empty contentYesNoYes
tool/result errorNot applicable (handled via the error field of tool/result)Yes (with isError)Not applicable

This "dual identity of empty messages" rule can help you avoid a pitfall in a real-world scenario: when doing token cost analysis, if you only look at the derived messages, you will miss those truncated empty calls; only by going back to the log layer can you get the complete usage statistics. This once again confirms that saying—for complete statistics and replay, consume the session/event event stream, don't just look at the derived results.

Event three-domain selection guide: the persistence and interception timing of session events, Agent events, and capability events

The last piece of the foundation is the division of event domains. The official documentation divides events into three domains, each with its own purpose.Choosing the right event domain is the first decision for most changes.This statement is especially important for developers building extensions—which domain you add an event to directly determines its persistence, interceptability, and scope of effect.

Here is the comparison table first; this is the thing from this section most worth bookmarking:

Event domainRepresentative eventsCharacteristicsWhen to use
Session eventsturn/start, step/start, user/message, assistant/*, tool/call, tool/resultAppended to the log and broadcast, persistent factsWhen a fact must still exist after reloading
Agent eventsagent/pre-step, agent/request, agent/status, agent/turn-stoppingCarry the active Agent, real-time control and statusObserving or intercepting work in progress
Capability eventstools/*, fs/*, llm/streamAttach policies to seams without import cyclesAttaching policies and adapters to capability seams

Breaking it down domain by domain:

  1. Session events. The representatives are turn/start, step/start, user/message, assistant/*, tool/call, tool/result. Their core characteristic is that they are appended to the log and broadcast—they are durable facts. The criterion is clear: if a fact must still exist after a reload, it should be modeled as a session event. For example, "what the user sent," "what the model replied," and "what the tool returned" are all durable facts that must be persisted and must be replayable. They serve as the source of all raw material for replay and auditing.
  2. Agent events. The representatives are agent/pre-step, agent/request, agent/status, agent/turn-stopping. Their characteristic is that they carry an active Agent and are used for real-time control and state. The use case is observing or intercepting work in progress. For example, agent/pre-step determines what the model sees—listeners can rewrite the messages that have been claimed, or reject them outright. These events are concerned with "what is happening right now, and should I intervene," not "what happened in history." Note the timing detail specifically called out in the source material: when the first claim is rejected or rewritten to empty, a durable turn with no steps is still closed, so the log records this attempt. In other words, even if you intercept an input and nothing is executed, the system still leaves behind a durable turn record with no steps—"an attempt was made" is itself a fact that must be recorded.
  3. Capability events. The representatives are tools/*, fs/*, llm/stream. Their characteristic is that they attach policies to seams without import cycles. The use case is hanging policies and adapters onto capability seams. The value of these events lies in the "seam"—the capability seam. If you want to add a layer of permission checking to tool calls, a layer of sandboxing to the file system, or a layer of auditing to LLM streaming output, you should mount them through capability events rather than modifying session events. Their design goal is to avoid import cycles between modules, allowing policies to be attached to capability boundaries in a plugin-like manner.

One mechanism difference that is easy to trip over also deserves emphasis here: the distinction between waterfall and serial events. The source material explicitly points out that agent/pre-step, agent/request, llm/stream, and the three tools/* events are waterfall, and listeners must call next() to delegate further; whereas agent/turn-stopping is a serial event and has no next().

  • waterfall: multiple listeners are chained in order, and each listener calls next() after processing to hand control to the next one. This gives every listener the power to "rewrite, short-circuit, or bypass"—if you don't call next(), you cut off subsequent processing. It's suited to policy interception scenarios (such as rewriting messages in pre-step).
  • serial: listeners execute in sequence, but there is no next() delegation semantics, so the concept of "cutting off the chain" doesn't exist. It's suited to notification-style scenarios (such as state cleanup at turn-stopping).

Combining "which domain to choose" with "which dispatch mode to use" forms a decision tree for extension development: need persistent traces → session events; need to intercept work in progress in real time → Agent events (and mostly waterfall, don't forget next()); need to attach policies to capability boundaries → capability events (also mind next()). Anyone who has stepped into the waterfall pitfall knows that forgetting next() causes subsequent listeners to silently not execute, manifesting as "why isn't my plugin taking effect?" with not a single anomaly in the logs—this is the most common "ghost bug" for beginners.

Going back through the flowchart once more will give you a more complete understanding. The official sequence diagram depicts the full flow as: turn/start → agent/pre-step → step/start → llm/stream → tools → step/end → turn/end. Among these, turn/start, step/start, step/end, and turn/end are session events (persistence boundaries), agent/pre-step is an Agent event (real-time interception point), and llm/stream is a capability event (policy attachment point). The three domains each play their role on the same timeline, together supporting the entire observability skeleton.

At this point, the three foundations of part 1/2 are laid out: a session is an append-only log, with seq and time forming the coordinate system; message history is a projection, not stored separately, and replay means re-projection; events form a discriminated union by type, and data is forced to lossless JSON at append time; "model-visible means already recorded" is an inviolable runtime invariant; deriveMessages projects according to a rule table, is cached, and returns a frozen array; empty-content assistant/message stores usage but does not enter the transcript; the choice of the three domains determines whether your changes can persist and whether they can intercept. In the next part 2, we'll put these mechanisms into real engineering—starting from a JSONL log, we'll do trajectory replay by hand, compare derived views against the original events, and handle the trickiest edge cases during replay (such as missing sourceEventSeqs and cache invalidation caused by surface rewrites). If you want to get hands-on right now, you might as well save that Python script above, grab one of your own session logs, and run it once to get a feel for what "the same log, two views" is actually like.

In the previous section, we started from the question "where exactly does the conversation history that the model sees live?" and took apart how Session, as an append-only event log, serves as the single source of truth, how deriveMessages() projects that log into the model-visible Message[], and the basic division of labor across the three event domains. In this part, we dig deeper: the propagation semantics of events on the seam, the boundary definitions of turns and steps, the full walkthrough of the official sequence diagram, and the concrete fields of key events such as turn/end, tool/call, and tool/result—finally landing on a runnable JSONL replay script and a set of observability engineering practices.

waterfall vs. serial: agent/pre-step, agent/request, llm/stream, and tools/* must call next()

To understand the turn lifecycle, you first need to understand how events propagate across the seam (capability seam). Events in DeepSeek Harness are not simply "broadcast and done"—some of them are waterfall events, which are passed down the listener chain in order, and each listener must explicitly call next() to delegate control to the next listener; otherwise the chain is truncated on the spot. According to the official documentation, the waterfall events that require calling next() to delegate are:

  • agent/pre-step: the last rewrite checkpoint before the model request, determining what the model ultimately sees.
  • agent/request: the interception point before the request is sent, used to attach policies or rewrite the request.
  • llm/stream: an intermediate link on the streaming response path, used to observe or rewrite streaming output.
  • tools/\*: the three capability events (the series of events in the tools domain), used to attach policies and adapters to the tool seam.

In contrast are serial events: they have no next(), do not form a delegation chain, and a typical representative is agent/turn-stopping—triggered when a turn is about to stop, used for state finalization and observation rather than for intercepting or rewriting the flow. This distinction is easy to get wrong: if you treat agent/turn-stopping as a waterfall and call next(), it will throw an error at runtime; conversely, if you forget to call next() in agent/pre-step, the chain will be silently truncated, subsequent listeners will no longer execute, and the model may receive a request that has been "frozen" prematurely—making it very hard to troubleshoot.

EventDomainPropagation SemanticsWhether next() Is RequiredTypical Use
agent/pre-stepAgent eventwaterfallMust callRewrite or reject claimed input, determine what the model can see
agent/requestAgent eventwaterfallMust callAttach policies before the request is sent, observe request shape
llm/streamCapability eventwaterfallMust callObserve and adapt the streaming path
tools/*Capability eventwaterfallMust callAttach policies and adapters to the tool seam
agent/turn-stoppingAgent eventserialNo next()Finalize state and observe before the turn stops

An engineering-minded approach is to fold the "must call next()" requirement into the type system or lint rules. Because next is a required parameter in the signature of a waterfall listener, failing to call it often still passes type checking in TypeScript (you simply didn't use it), yet it is a bug at runtime. A pragmatic practice is to impose a single convention on all waterfall listeners: any early return must correspond to an explicit "short-circuit decision", not a "forgotten delegation." The following TypeScript snippet shows the registration difference between waterfall and serial on the same seam, and can be used directly as an integration template:

// Register a waterfall listener: you must call next() to delegate further
agent.on("agent/pre-step", async (event, next) => {
  const claimed = event.data.messages;

  // Decision one: reject this claim outright, short-circuiting the entire chain
  if (shouldReject(claimed)) {
    return { rejected: true };
  }

  // Decision two: rewrite the claimed messages, then delegate to the next listener
  const rewritten = claimed.map((m) => rewrite(m));
  return next({ ...event, data: { ...event.data, messages: rewritten } });
  // Note: if you forget return next(...) here, the chain is silently truncated
});

// Register a serial listener: there is no next(), do not attempt to delegate
agent.on("agent/turn-stopping", (event) => {
  recordTurnStopping(event.data.turn, Date.now());
  // There is no next parameter here, and next() should not appear either
});

Defining the Boundary Between Turns and Steps: One Model Request Plus Its Tool Calls, a Turn Enclosing One Model Loop

Once the propagation semantics are clear, the boundaries fall into place naturally. The official documentation defines them very sparingly:

  • A step is one model request, plus the tools invoked by that request.
  • A turn contains zero or more steps: it opens before claiming the first input and closes when no further work is owed.

The most easily misunderstood point is that "a turn encloses one execution of the model loop, not the entire session log." In other words, a turn is not synonymous with "a session"; it is merely a segment of execution boundary on the session log. A single session can have many turns, and a turn can have zero steps (for example, when input is rejected during the pre-step phase). The log records both levels of boundaries precisely so that replay can accurately reconstruct "which model request occurred within which segment of context."

Why does the definition of a step emphasize "plus the tools it invokes"? Because a step is not "a single LLM call," but rather the closed loop of "one model request and the tool round-trips it triggers." The model request returns tool_use, the system executes the tools and feeds tool/result back as the user role—this entire set of actions belongs to the same step; the next model request opens a new step. This explains why the log contains both assistant/message and tool/call and tool/result between step/start and step/end.

示意图
Nested boundaries of turns and steps on the session log: turn/start opens a turn, which contains zero or more steps, each step consisting of one model request and its tool calls, and finally closes with turn/end.

Mapping the definitions onto log events, the boundaries become a set of one-to-one corresponding event pairs. The following table is a quick reference for the fields most commonly encountered in engineering:

EventData CarriedDescription
turn/start{ turn }Opens a turn before the loop claims queued input or runs the pre-step
turn/end{ turn, reason }Closes the turn with a TurnEndReason (completed / aborted / blocked / error / max-tokens / interrupted)
step/start{ turn, step }Opens a step within a turn
step/end{ turn, step }Closes that step

Note that turn/start carries only a { turn }, whereas turn/end carries { turn, reason }. This asymmetry is deliberate: the outcome is not predetermined when a turn begins, and only at the moment it closes does the system know whether it ended due to completion, being aborted, being blocked, an error, being truncated by max-tokens, or being interrupted. This also means that if, during replay, you see a turn/start with no paired turn/end, you can only conclude that this execution "did not finish cleanly."

Walking through the official sequence diagram: turn/start → agent/pre-step → step/start → llm/stream → tool → step/end → turn/end

The official sequence diagram depicts a complete flow as this chain:

turn/start → agent/pre-step → step/start → llm/stream → tool → step/end → turn/end

Let's read through it segment by segment. turn/start opens the turn before the loop claims queued input or runs the pre-step, and this is the starting point of the entire execution boundary. Next comes agent/pre-step: it is the last decision point before the model request, able to rewrite already-claimed messages or reject outright. Once the decision passes, step/start opens the first step within that turn, and llm/stream carries the streaming response (token-level chunks land in assistant/chunk). If the model initiates a tool call, we enter the "tool" segment—tool/call records the request, the tool executes, and tool/result records the result visible to the model. After the tool round-trip is consumed, step/end closes the current step; if the model has further actions, the next step/start opens again. When no work remains outstanding, turn/end closes the turn.

There is a very critical input-side detail here: input reaches the driver through the same inbox. It is not that each entry point has its own separate queue; instead, everything goes into a single unified inbox. Among these, "some messages immediately wake the driver," such as a direct user prompt; while "injected context stays in the inbox until another message wakes it." This distinction matters greatly for observability: the user/message events you see in the logs may come from a direct prompt, or from injected context, steering, or real-time inbox events—they all share the same tagged value type UserMessage. During replay, if you look only at content, you will misjudge whether a message was "spoken by the user" or "injected by the system"; fortunately, every user/message carries a tag, and only together with surfaceOp can you reconstruct how it entered the derived surface.

Translating this sequence chain into an observability perspective, it can be broken down into three categories of observation points:

  1. Boundary observation: turn/start, step/start, step/end, turn/end, used to reconstruct execution structure and timing.
  2. Content observation: user/message, assistant/chunk, assistant/message, tool/call, tool/result, used to reconstruct what the model saw and produced.
  3. Control observation: agent/pre-step, agent/request, agent/status, agent/turn-stopping, used to observe or intercept work in progress.

These three types of observation points correspond to three domains: session events, Agent events, and capability events. Session events are persistent facts, appended to the log and broadcast, suited for "still present after reload"; Agent events carry the active Agent, suited for real-time control and state; capability events are used to attach policies to seams without import cycles. Choosing the right event domain is the first decision in most changes.

agent/pre-step decides what the model sees: rewriting, rejecting, and "empty turns still get logged"

agent/pre-step is the most awe-inspiring link in the entire lifecycle, because it determines what the model ultimately sees. Listeners have two kinds of actions here: rewrite the claimed messages, or reject them outright. Rewriting means you can insert, delete, or replace context before the model sees it; rejecting means this claim does not happen, and the model will not receive this batch of input.

There is a rule in the official documentation that is easy to overlook but extremely important: when the first claim is rejected or rewritten to empty, a persistent turn without steps is still closed, so the log records this attempt. In other words, "nothing happened" still leaves a trace. This directly echoes the design philosophy of the session log as the single source of truth, and is also an extension of the invariant that "what is visible to the model is already recorded": everything that reaches the model request must be reconstructable from the log. Conversely, a rejected attempt is also part of the complete interaction history and cannot vanish into thin air.

This rule is extremely valuable for troubleshooting. Imagine a scenario: the user says "help me fix the typo in the README," but your policy layer rejects the input in pre-step due to permissions or guardrails. Without "empty turns get logged," you would see nothing in the log and mistakenly assume the driver never received the message; with this rule, a pair of turn/start and turn/end will appear in the log, with no step/start in between, and the reason will most likely fall into the blocked tier. This is the meaning of "a persistent turn without steps": it turns "a rejected attempt" into a queryable fact rather than letting it disappear into a black hole.

Paired with pre-step are the two types of event semantics mentioned earlier: agent/pre-step, agent/request, llm/stream, and the three tools/* events are all waterfalls, and listeners must call next() to delegate further; agent/turn-stopping is a serial event and has no next(). Therefore, a robust pre-step listener should explicitly express three exits: delegation (return next(...)), delegation after rewriting, and rejection short-circuiting. As for the difference between rewriting to empty and rejecting outright, from an observability perspective both produce "a turn without steps," but their semantics differ—one is "no content after policy filtering," and the other is "explicit rejection." They need to be judged together with the reason and your business logs.

The full range of TurnEndReason for turn/end: completed / aborted / blocked / error / max-tokens / interrupted

turn/end carries { turn, reason }, where reason is a discrete TurnEndReason. Break it down one by one, and you basically get the full picture of round state transitions:

reason tierMeaningTypical causeTroubleshooting hint
completedNormal completionNo outstanding work remains; the model loop converges naturallyHealthy path; usable as a baseline reference
abortedAbortedExternal cancellation or an active stopDistinguish "who initiated the cancellation" and cross-check caller logs
blockedBlockedIntercepted by policy or guardrails, commonly a pre-step rejectionOften accompanied by an empty round with no steps
errorErrorAn exception was thrown during executionNeeds to be located together with error and upstream tool logs
max-tokensTruncatedReached the max-tokens limitLeaves behind a contentless assistant/message to preserve usage
interruptedInterruptedExecution was interrupted externallyDistinguish from aborted: interruption leans more toward a runtime interruption

Viewed together with the { turn } data shape of turn/start, the round state transitions become very clear: turn/start only declares "round N has begun" and carries no expected outcome; only at turn/end does reason settle it. Therefore, when reconstructing a state machine from logs, the correct approach is to maintain a per-turn state mapping: set it to "in progress" on turn/start, settle the terminal state by reason on turn/end, and raise a separate alert for dangling rounds that have only a start and no end.

Among these, the max-tokens tier has a very specific field-level detail, and the material states it clearly: a step truncated by max-tokens with no content will still record an assistant/message to preserve usage, provider, and model, but a contentless assistant round must not enter the provider transcript. In other words, this assistant/message event is a real, persisted event whose purpose is to retain metadata such as usage, provider, and model; but it is skipped when projected into model history—a contentless assistant/message is also skipped. This is a classic "separation of persistence and projection" design: the log is responsible for recording every fact, while the derived history is only responsible for showing the model what it should see.

Field details of tool/call and tool/result: callId, raw JSON string arguments, precise traceback via sourceEventSeqs

The tool round-trip is the most error-prone part of Agent execution, so the fields are designed to be quite restrained and traceable. First, look at tool/call: it carries { turn, step, callId, name, arguments }. The most critical point here is that arguments is the raw JSON string produced by the model, not a structure that has already been parsed into an object. This is extremely important: the JSON generated by the model may be malformed, may contain trailing commas, or may be fragmented by streaming. Preserving the raw string means that when troubleshooting, you can see what the model "actually emitted," rather than a blank after parsing fails. Parsing is your job, not the log's job.

The callId in tool/call is the key that connects the request and the result: tool/result carries { turn, step, message, error?, meta? }, where message is "the model-visible result of one completed tool call," and error? and meta? are optional fields that carry error information and additional metadata, respectively. callId makes it possible to precisely pair "which call produced this result," especially when multiple tool calls are initiated in parallel within the same step, where without callId the correspondence cannot be reconstructed.

Now look at sourceEventSeqs. The assistant/message event precisely lists the corresponding assistant/chunk events through sourceEventSeqs, including an explicit empty list. This detail of the "explicit empty list" deserves separate emphasis: it means that "this assistant/message has no corresponding streaming chunks" is an explicitly recorded fact, rather than "unknown because it was not written." In observability, an explicit empty value and a missing value have completely different semantics—the former is "known to be empty," while the latter is "unknown." The replay pipeline must be able to distinguish these two cases in order to correctly determine whether an assembled message should have come from streaming.

EventKey fieldsField semanticsWhy it matters
tool/callcallIdIdentifier of this tool callPrecisely pairs with tool/result and supports reconstruction of parallel calls
tool/callargumentsRaw JSON string produced by the modelPreserves malformed/unparsed form for easier troubleshooting
tool/resultmessageModel-visible tool resultThe tool result returns to the model in the user role
tool/resulterror? / meta?Optional error and metadataDistinguishes failed results from additional context
assistant/messagesourceEventSeqsList of corresponding assistant/chunk sequence numbersPrecisely traces the source chunks of the assembled message, including an explicit empty list

There is one more detail at the projection layer: tool/result is projected as "a user message with a tool-result block" — the tool result returns to the model in the user role. assistant/chunk, however, is skipped during projection because it belongs to replay/UI data, and the assembled assistant/message is the authoritative one; tool/call is also not projected as a message, since it is a structured fact. Understanding "which events are projected and which are not" is a prerequisite for writing a replayer.

Hands-on parsing of JSONL session logs: surfaceOp markers and boundary events that are not projected

Below, a real-shaped session log ties the earlier rules together. The material provides a short JSONL snippet (canonical packed line layout) from a task to "fix a typo in the runoob repository." Each line is a SessionEvent containing type, seq, time, and data:

{"type":"turn/start","seq":0,"time":1755000000000,"data":{"turn":1}}
{"type":"step/start","seq":1,"time":1755000000010,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":2,"time":1755000000020,"data":{"role":"user","content":[{"type":"text","text":"Fix the typo in the runoob README."}]},"surfaceOp":"append","sourceEventSeqs":[0]}
{"type":"assistant/chunk","seq":3,"time":1755000000030,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","text":"I'll "}}}
{"type":"assistant/chunk","seq":4,"time":1755000000040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","name":"bash","arguments":"{\"command\":\"grep runoob README.md\"}"}}}
{"type":"assistant/message","seq":5,"time":1755000000050,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I'll search"},{"type":"tool_use","id":"call_1","name":"bash","input":{"command":"grep runoob README.md"}}]},"usage":{"inputTokens":12,"outputTokens":4}},"surfaceOp":"append","sourceEventSeqs":[3,4]}
{"type":"tool/call","seq":6,"time":1755000000060,"data":{"turn":1,"step":1,"callId":"call_1","name":"bash","arguments":"{\"command\":\"grep runoob README.md\"}"}}
{"type":"tool/result","seq":7,"time":1755000000070,"data":{"turn":1,"step":1,"message":{"role":"tool","toolName":"bash","content":"runoob","isError":false}},"surfaceOp":"append","sourceEventSeqs":[6]}
{"type":"step/end","seq":8,"time":1755000000080,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":9,"time":1755000000090,"data":{"turn":1,"reason":{"kind":"completed"}}}

This log contains several patterns worth verifying one by one. First, seq is monotonically increasing, and the material explicitly states seq = log.length, meaning the sequence number is itself the log length—naturally contiguous and naturally unique; time is in epoch milliseconds. Second, the three surface events user/message, assistant/message, and tool/result carry a surfaceOp marker, indicating how they are added to the derived surface (here all are "append"). Third, boundary events such as turn/start and step/start do not carry a surfaceOp and are not projected into model messages—they are structural information, not content.

Now look at how sourceEventSeqs is used: the sourceEventSeqs of user/message (seq 2) is [0], pointing to turn/start; the sourceEventSeqs of assistant/message (seq 5) is [3, 4], corresponding exactly to the two assistant/chunk events; the sourceEventSeqs of tool/result (seq 7) is [6], pointing to tool/call. This back-reference chain makes the correspondence between the "assembled result" and the "original chunks/calls" fully traceable.

Next, let's replay this log in Python to reconstruct the conversation and mark the turn/step boundaries. This is a simplified teaching model of Session.deriveMessages(); the real implementation is cached—each surface node is projected once when it first appears and rebuilt when the surface is rewritten—and returns a frozen message array, so modifying recorded history through projection is unrepresentable at the type level:

# File path: examples/parse_session_log.py
# Parse a JSONL session log, reconstruct the model-visible conversation, and mark turn/step boundaries.
import json
import sys

def derive_messages(events):
    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            if d["message"].get("content"):
                messages.append({"role": "assistant", "content": d["message"]["content"]})
        elif t == "tool/result":
            messages.append({"role": "tool", "name": d["message"]["toolName"], "content": d["message"]["content"]})
        # turn/*, step/*, assistant/chunk, tool/call are not projected into messages
    return messages

def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # First pass: print execution boundaries to understand the nesting of turn and step.
    for ev in events:
        d = ev["data"]
        if ev["type"] == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif ev["type"] == "turn/end":
            print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
        elif ev["type"] == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "step/end":
            print(f"  [step/end] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "assistant/chunk":
            print(f"  chunk: {d['chunk']['type']}")
        elif ev["type"] == "tool/call":
            print(f"  tool/call: {d['name']} args={d['arguments']}")

    # Second pass: reconstruct the model-visible derived history.
    print("\nModel-visible derived messages:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")

if __name__ == "__main__":
    main(sys.argv[1])

Save it as examples/parse_session_log.py, prepare a session.jsonl, and run it directly:

python examples/parse_session_log.py session.jsonl

There are two pedagogical simplifications here that need to be explained. First, the real implementation is cached and returns frozen messages: a surface node is projected once when it first appears and rebuilt on rewrite; the returned Message[] is type-level immutable, preventing you from rewriting recorded history through projection. Second, in the example I added an extra "skip if content is empty" check for assistant/message, because the source material explicitly states that assistant/message with empty content is skipped—but that event itself is still retained in the log to preserve usage, provider, and model. If you blindly append in your implementation, you'll inject empty content into the model history, which is precisely the most common projection bug in max-tokens scenarios.

There's also an engineering pitfall worth flagging: in this log, the message.role of tool/result is "tool", but according to the projection rules, tool/result should be projected as "a user message with a tool-result block"—tool results return to the model under the user role. The teaching script preserves the original role for intuitive printing, but a real implementation must assemble it in the role shape required by the provider, otherwise the next request will be rejected due to a role mismatch. This kind of inconsistency between "log shape" and "projection shape" is where things most easily go wrong when writing a replayer.

Latest Practice as of September 2026: Consuming Session Logs as an Observability Data Plane

By September 2026, one clear trend around Agent observability is: no longer treating logs as a "debugging accessory", but consuming them as a first-class data plane. The design of DeepSeek Harness aligns perfectly with this direction—the session/event event stream itself is a replayable transcript data source. The official guidance is: SDK users who need replayable transcript data should consume the session/event event stream. Combined with the mechanisms already present in the source material, a complete practical pipeline can be built:

  1. Collection layer: Subscribe to the session/event event stream, and preserve the raw form of type, seq, time, and data when persisting—especially do not discard sourceEventSeqs and surfaceOp. The former is responsible for "where this content comes from", and the latter for "how it enters the derived surface".
  2. Reconstruction layer: Rebuild the model-visible history using projection rules consistent with deriveMessages, ensuring that "what you see on replay is what the model saw at the time". Since model-visible means recorded, this reconstruction chain is complete in principle—anything that reached a model request can be reconstructed from the log.
  3. Traceback layer: Pair tool/call with tool/result by callId, and trace back from assistant/message to the specific assistant/chunk via sourceEventSeqs. When investigating "why did the model say this", drill directly down the chain to the token-level chunks.
  4. Alerting layer: Monitor the distribution of turn/end reasons, paying particular attention to error, max-tokens, blocked, and dangling turns that have only turn/start but no turn/end.
  5. Extension layer: For every new model-visible input, a new session event must be added—extend SessionEventMap and render from the log. This constraint ensures the observability surface won't miss new inputs due to feature iteration.

Why is "adding a new model-visible input requires adding a new session event" so critical? Because it turns observability from "retrofitting instrumentation after the fact" into "structural enforcement." If some new input bypasses the log, it cannot be reconstructed, and replay will diverge from actual execution, which directly violates a runtime invariant. Since events form a true discriminated union based on type, switch (event.type) can directly narrow event.data without type assertions; and all event.data must be losslessly serializable to JSON, which Session.append enforces at the source. This means your observability pipeline always receives structured, serializable events, and you don't need to write a pile of defensive sludge to handle "inconsistent formats."

When turning this pipeline into an everyday tool, I recommend exposing at least three query capabilities: query all events by turn (reconstruct one loop), query tool round-trips by callId (reconstruct one tool call), and reverse-lookup by sourceEventSeqs (reconstruct the full provenance of a message). Together, these three form the minimum viable set for "trajectory replay."

Summary and Best Practices

Condensing the entire article into an actionable checklist:

  • The log is the source of truth: A Session is an append-only log composed of typed SessionEvents, and it is the single source of truth for the agent's complete interaction history; the LLM message history is derived from the log and never stored separately; replay means re-deriving history from the same set of events.
  • Uphold the invariant: "Model-visible means recorded"—everything that reaches a model request must be reconstructable from the log, asserted by a runtime invariant. To add a new model-visible input, extend SessionEventMap and add a new session event.
  • Choose the right event domain: Session events (turn/start, step/start, user/message, assistant/*, tool/call, tool/result) are persistent facts; Agent events (agent/pre-step, agent/request, agent/status, agent/turn-stopping) are for real-time control; capability events (tools/*, fs/*, llm/stream) are for attaching policies and adapters to seams.
  • Distinguish propagation semantics: agent/pre-step, agent/request, llm/stream, and the three tools/* are waterfalls, and next() must be called to delegate; agent/turn-stopping is serial and has no next().
  • Clarify boundaries: A step is one model request plus the tools it invokes; a turn contains zero or more steps, opens before claiming the first input, closes when no work remains owed, and encloses only one model loop rather than the entire session.
  • Take empty turns seriously: When the first claim is rejected or rewritten to empty, a persistent turn with no steps is still closed, and the log records the attempt—don't treat "nothing happened" as "it didn't happen."
  • Watch the reason on turn/end: The six categories completed / aborted / blocked / error / max-tokens / interrupted, together with { turn } from turn/start, form the complete turn state transition; alert separately on dangling turns.
  • Preserve the original form: The arguments of tool/call are the raw JSON string produced by the model; tool/result carries error?/meta?; assistant/message uses sourceEventSeqs to precisely list the corresponding chunks, including an explicit empty list.
  • Projection rules must be strict: user/message → user message; assistant/message → assistant message (skipped if content is empty); tool/result → user message with a tool-result block; assistant/chunk, tool/call, and turn/*, step/* are not projected as model messages.
  • Replay must be cached and frozen: deriveMessages is cached; a surface node is projected once when it first appears and rebuilt on rewrite; returning a frozen array makes "modifying recorded history through projection" unrepresentable at the type level.
  • Consume session/event: SDK users who need replayable transcript data should consume the session/event event stream, providing at least three query capabilities: by turn, by callId, and by sourceEventSeqs.