From aafc28c23ebf85515fa247e0be6d08a1c77f53ee Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:32:19 +0800
Subject: [PATCH 1/9] test: preserve observed usage through worker restart
---
CONTRIBUTING.md | 10 ++++++++
internal/agentdaemon/proto/inbound.go | 13 ++++++----
.../internal/store/public_execution_test.go | 25 +++++++++++++++++++
3 files changed, 43 insertions(+), 5 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 906921a59..673c051f9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1649,6 +1649,16 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti
Cancellation receipts carry the stopped engine's continuity snapshot when no
Done is emitted. Preserve separately reported usage on failure; do not add the
same counters again when Done also includes them.
+ Usage frames carry cumulative snapshots for the current execution, not deltas.
+ Adapters publish observed snapshots promptly through the same ordered stream;
+ waiting for Done unnecessarily loses known measurements if the Runtime stops.
+ Core replaces complete valid token breakdowns and preserves the last committed
+ measurement on interruption. Missing measurements remain unknown. Do not infer
+ token consumption from context occupancy or estimated costs, or parse native
+ Raw payloads in Core. Session totals cover recorded root Turns; mixed root/child
+ Turn listings are not a summable accounting ledger. Native measurement coverage
+ and exact provider/model attribution remain explicit qualification boundaries.
+ No separate public usage event or historical SSE replay is introduced.
- The dispatcher is an internal entry point used by the standalone service worker.
Legacy internal `daemon` configuration is not a public Environment type.
Self-hosted enrollment uses exact local binding; further pending interactions
diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go
index 20dc08183..1b2770ee0 100644
--- a/internal/agentdaemon/proto/inbound.go
+++ b/internal/agentdaemon/proto/inbound.go
@@ -52,7 +52,8 @@ const (
// frame arrives.
TypeInteractionDecisionAck = "interaction_decision_ack"
- // TypeUsage reports incremental token / cost usage.
+ // TypeUsage reports a cumulative usage snapshot for the current execution.
+ // Repeated snapshots, including the final Done snapshot, replace rather than add.
TypeUsage = "usage"
// TypeError signals the prompt failed. Daemon MUST emit a Done
@@ -197,10 +198,9 @@ func (p PromptForUserChoicePayload) EffectiveQuestions() []PromptForUserChoiceQu
}}
}
-// Usage mirrors server/internal/store.UsageInput on the wire — field
-// names and JSON tags identical so the connector boundary copies with
-// a one-liner translator. Redeclared (not imported) because this
-// package must stay free of server/internal dependencies.
+// TokenUsage is a complete cumulative measurement for the current execution.
+// Adapters publish observed snapshots promptly; historical resume totals belong
+// to the adapter baseline, not this execution. Unknown fields must not become zero.
type TokenUsage struct {
InputTokens int64 `json:"input_tokens"`
CachedInputTokens int64 `json:"cached_input_tokens"`
@@ -209,6 +209,9 @@ type TokenUsage struct {
TotalTokens int64 `json:"total_tokens"`
}
+// Usage retains native evidence independently of the optional public breakdown.
+// Tokens is absent when a complete, correctly scoped measurement is unavailable.
+// Core does not reconstruct token counts, prices or provider identity from Raw.
type Usage struct {
Tokens *TokenUsage `json:"tokens,omitempty"`
Provider string `json:"provider,omitempty"`
diff --git a/services/agents-api/internal/store/public_execution_test.go b/services/agents-api/internal/store/public_execution_test.go
index c6b00bf61..14557e3e6 100644
--- a/services/agents-api/internal/store/public_execution_test.go
+++ b/services/agents-api/internal/store/public_execution_test.go
@@ -117,6 +117,30 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) {
if _, err := h.s.TransitionTurn(ctx, h.tenant, h.session.ID, first.TurnID, store.TurnTransition{ExpectedStatus: store.TurnQueued, Status: store.TurnInProgress}); err != nil {
t.Fatal(err)
}
+ // A native measurement committed before process loss must survive startup
+ // reconciliation even when no Done frame can be recovered.
+ usage := json.RawMessage(`{"tokens":{"input_tokens":10,"cached_input_tokens":4,"output_tokens":3,"reasoning_output_tokens":2,"total_tokens":13}}`)
+ if err := h.s.AppendTurnEvents(ctx, h.tenant, h.session.ID, first.TurnID, 1, []store.ExecutionEvent{{Kind: proto.TypeUsage, Payload: usage}}); err != nil {
+ t.Fatal(err)
+ }
+ checkMeasurement := func() {
+ t.Helper()
+ turn, err := h.s.GetTurn(ctx, h.tenant, h.session.ID, first.TurnID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ TotalTokens int64 `json:"total_tokens"`
+ }
+ if json.Unmarshal(turn.Usage, &got) != nil || got.TotalTokens != 13 {
+ t.Fatalf("lost observed usage: %s", turn.Usage)
+ }
+ session, err := h.s.GetSession(ctx, h.tenant, h.session.ID)
+ if err != nil || string(session.Usage) != string(turn.Usage) {
+ t.Fatalf("Session and Turn measurement differ: %+v %v", session, err)
+ }
+ }
+ checkMeasurement()
queued := publicSession(t, h, "queued")
if _, err := h.s.SubmitMessage(ctx, h.tenant, queued.ID, "first", json.RawMessage(`{"text":"Not sent"}`)); err != nil {
t.Fatal(err)
@@ -152,4 +176,5 @@ func TestWorkerRestartReconcilesClaimedButPreservesQueuedWork(t *testing.T) {
if err := restarted.Run(stopped); err != context.Canceled {
t.Fatal(err)
}
+ checkMeasurement()
}
From a671f008c8a39dd8ddd07785bfa8ba35b1ce8c97 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:35:41 +0800
Subject: [PATCH 2/9] fix: publish Codex usage snapshots during active turns
---
.../internal/agent/codex/session_usage.go | 30 ++-
.../agent/codex/session_usage_live_test.go | 197 ++++++++++++++++++
.../agent/codex/session_usage_test.go | 8 +-
3 files changed, 223 insertions(+), 12 deletions(-)
create mode 100644 apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go
diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage.go b/apps/parsar-daemon/internal/agent/codex/session_usage.go
index 7f03a9c54..9f9b310ff 100644
--- a/apps/parsar-daemon/internal/agent/codex/session_usage.go
+++ b/apps/parsar-daemon/internal/agent/codex/session_usage.go
@@ -33,8 +33,16 @@ func (s *Session) onUsageUpdated(raw json.RawMessage) {
if s.terminal.Load() {
return
}
+ var observed *TurnUsage
s.usageMu.Lock()
- defer s.usageMu.Unlock()
+ defer func() {
+ s.usageMu.Unlock()
+ // Native notifications are ordered by the RPC reader. Publish before it
+ // reads completion, without holding the snapshot lock across backpressure.
+ if observed != nil {
+ s.emitUsage(*observed)
+ }
+ }()
if p.TokenUsage != nil && p.TokenUsage.Total != nil && p.TurnID != "" {
// app-server replays the previous thread total after resume and
// before turn/started. It establishes a baseline, not new usage.
@@ -47,19 +55,20 @@ func (s *Session) onUsageUpdated(raw json.RawMessage) {
}
s.usageTotal = *p.TokenUsage.Total
u := subtractUsage(s.usageTotal, s.usageBaseline)
- s.latestUsage = &u
- return
- }
- if p.Usage != nil && s.usageTurnID != "" && (p.TurnID == "" || p.TurnID == s.usageTurnID) {
+ observed = &u
+ } else if p.Usage != nil && s.usageTurnID != "" && (p.TurnID == "" || p.TurnID == s.usageTurnID) {
u := *p.Usage
- s.latestUsage = &u
+ observed = &u
+ }
+ if observed != nil {
+ s.latestUsage = observed
}
}
func subtractUsage(total, baseline TurnUsage) TurnUsage {
return TurnUsage{
observed: true,
- complete: total.complete && (!baseline.observed || baseline.complete) &&
+ complete: total.completeTokens() && (!baseline.observed || baseline.completeTokens()) &&
total.InputTokens >= baseline.InputTokens && total.OutputTokens >= baseline.OutputTokens &&
total.CachedInputTokens >= baseline.CachedInputTokens &&
total.ReasoningOutputTokens >= baseline.ReasoningOutputTokens && total.TotalTokens >= baseline.TotalTokens,
@@ -93,9 +102,14 @@ func (u *TurnUsage) UnmarshalJSON(raw []byte) error {
return nil
}
+func (u TurnUsage) completeTokens() bool {
+ return u.complete && u.CachedInputTokens <= u.InputTokens && u.ReasoningOutputTokens <= u.OutputTokens &&
+ u.InputTokens <= u.TotalTokens && u.OutputTokens == u.TotalTokens-u.InputTokens
+}
+
func (s *Session) usagePayload(u TurnUsage) proto.Usage {
result := proto.Usage{Provider: "openai", Model: s.resolvedModel, InputTokens: int32(u.InputTokens), OutputTokens: int32(u.OutputTokens)}
- if u.complete {
+ if u.completeTokens() {
result.Tokens = &proto.TokenUsage{InputTokens: int64(u.InputTokens), OutputTokens: int64(u.OutputTokens), CachedInputTokens: int64(u.CachedInputTokens), ReasoningOutputTokens: int64(u.ReasoningOutputTokens), TotalTokens: int64(u.TotalTokens)}
}
return result
diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go b/apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go
new file mode 100644
index 000000000..0672b6433
--- /dev/null
+++ b/apps/parsar-daemon/internal/agent/codex/session_usage_live_test.go
@@ -0,0 +1,197 @@
+package codex
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto"
+)
+
+const activeUsageNotification = `{"method":"thread/tokenUsage/updated","params":{"threadId":"thread","turnId":"turn","tokenUsage":{"total":{"inputTokens":30,"cachedInputTokens":4,"outputTokens":10,"reasoningOutputTokens":2,"totalTokens":40}}}}`
+
+func TestUsagePublishedBeforeCompletion(t *testing.T) {
+ out := make(chan proto.Envelope, 8)
+ s := &Session{runID: "run", out: out, cancelCtx: t.Context(), cfg: defaultSessionConfig()}
+ s.setThreadID("thread")
+ s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"old","tokenUsage":{"total":{"inputTokens":10,"cachedInputTokens":1,"outputTokens":3,"reasoningOutputTokens":1,"totalTokens":13}}}`))
+ if len(s.out) != 0 {
+ t.Fatal("resume baseline was published as current usage")
+ }
+ s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`))
+ var notification struct {
+ Params json.RawMessage `json:"params"`
+ }
+ if err := json.Unmarshal([]byte(activeUsageNotification), ¬ification); err != nil {
+ t.Fatal(err)
+ }
+ s.onUsageUpdated(notification.Params)
+ s.onUsageUpdated(notification.Params)
+ if s.terminal.Load() || len(s.out) != 2 {
+ t.Fatalf("expected repeated live snapshots before completion, got %d", len(s.out))
+ }
+ var payload proto.UsagePayload
+ event := <-out
+ if err := event.DecodePayload(&payload); err != nil {
+ t.Fatal(err)
+ }
+ want := proto.TokenUsage{InputTokens: 20, CachedInputTokens: 3, OutputTokens: 7, ReasoningOutputTokens: 1, TotalTokens: 27}
+ if event.Type != proto.TypeUsage || event.ID != "run" || payload.Tokens == nil || *payload.Tokens != want {
+ t.Fatalf("wrong live snapshot: %+v %+v", event, payload)
+ }
+ repeated := <-out
+ if repeated.Type != proto.TypeUsage || string(repeated.Payload) != string(event.Payload) {
+ t.Fatal("repeated cumulative snapshot changed the reported measurement")
+ }
+ // No terminal event is needed to observe or retain the current measurement.
+ if got := s.CancellationOutcome().Usage.Tokens; got == nil || *got != want {
+ t.Fatalf("live observation missing from cancellation outcome: %+v", got)
+ }
+}
+
+func TestUsageBackpressureKeepsSnapshotReadableAndCompletionOrdered(t *testing.T) {
+ s, _, server := cancellationTestSession(t)
+ out := make(chan proto.Envelope, 1)
+ s.out = out
+ s.out <- proto.Envelope{Type: "occupied"}
+ s.registerHandlers()
+ s.setThreadID("thread")
+ s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`))
+ written := make(chan error, 1)
+ go func() {
+ _, err := io.WriteString(server.ToClient, activeUsageNotification+"\n"+`{"method":"turn/completed","params":{"threadId":"thread","turn":{"id":"turn","status":"completed"}}}`+"\n")
+ written <- err
+ }()
+ waitForUsageBackpressure(t, s)
+ assertReadableUsageSnapshot(t, s)
+ if s.terminal.Load() {
+ t.Fatal("completion overtook the blocked live usage")
+ }
+ <-out
+ for _, kind := range []string{proto.TypeUsage, proto.TypeUsage, proto.TypeDone} {
+ select {
+ case event := <-out:
+ if event.Type != kind {
+ t.Fatalf("event %q, want %q", event.Type, kind)
+ }
+ if kind == proto.TypeDone {
+ var done proto.DonePayload
+ if err := event.DecodePayload(&done); err != nil || done.Usage.Tokens == nil || done.Usage.Tokens.TotalTokens != 40 {
+ t.Fatalf("completion lost its snapshot: %+v %v", done, err)
+ }
+ }
+ case <-time.After(time.Second):
+ t.Fatal("ordered native output did not resume")
+ }
+ }
+ if err := <-written; err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestCancellationReleasesUsageBackpressure(t *testing.T) {
+ s, _, server := cancellationTestSession(t)
+ s.out = make(chan proto.Envelope, 1)
+ s.out <- proto.Envelope{Type: "occupied"}
+ s.registerHandlers()
+ s.setThreadID("thread")
+ s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`))
+ requests := collectCancellationRequests(t, server, "response timeout")
+ written := make(chan error, 1)
+ go func() {
+ _, err := io.WriteString(server.ToClient, activeUsageNotification+"\n")
+ written <- err
+ }()
+ waitForUsageBackpressure(t, s)
+ assertReadableUsageSnapshot(t, s)
+ ctx, cancel := context.WithTimeout(t.Context(), 4*time.Second)
+ defer cancel()
+ if err := s.Cancel(ctx); err != nil {
+ t.Fatal("cancellation could not release usage backpressure", err)
+ }
+ if s.cancelCtx.Err() == nil || len(<-requests) != 1 {
+ t.Fatal("native cancellation did not settle")
+ }
+ if err := <-written; err != nil {
+ t.Fatal(err)
+ }
+ assertReadableUsageSnapshot(t, s)
+}
+
+func waitForUsageBackpressure(t *testing.T, s *Session) {
+ t.Helper()
+ deadline := time.Now().Add(time.Second)
+ for s.outMu.TryLock() {
+ s.outMu.Unlock()
+ if time.Now().After(deadline) {
+ t.Fatal("native usage did not reach the blocked output")
+ }
+ runtime.Gosched()
+ }
+}
+
+func assertReadableUsageSnapshot(t *testing.T, s *Session) {
+ t.Helper()
+ read := make(chan proto.DonePayload, 1)
+ go func() { read <- s.CancellationOutcome() }()
+ select {
+ case got := <-read:
+ if got.Usage.Tokens == nil || got.Usage.Tokens.TotalTokens != 40 {
+ t.Fatalf("missing observed snapshot: %+v", got.Usage)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("output backpressure held the usage snapshot lock")
+ }
+}
+
+func TestLiveUsageRequiresConsistentCompleteBreakdown(t *testing.T) {
+ for name, counters := range map[string]string{
+ "missing": `{"inputTokens":5,"outputTokens":2}`,
+ "null": `{"inputTokens":5,"cachedInputTokens":0,"outputTokens":2,"reasoningOutputTokens":null,"totalTokens":7}`,
+ "negative": `{"inputTokens":5,"cachedInputTokens":-1,"outputTokens":2,"reasoningOutputTokens":0,"totalTokens":7}`,
+ "total": `{"inputTokens":5,"cachedInputTokens":0,"outputTokens":2,"reasoningOutputTokens":0,"totalTokens":8}`,
+ "cache": `{"inputTokens":5,"cachedInputTokens":6,"outputTokens":2,"reasoningOutputTokens":0,"totalTokens":7}`,
+ "reasoning": `{"inputTokens":5,"cachedInputTokens":0,"outputTokens":2,"reasoningOutputTokens":3,"totalTokens":7}`,
+ "large sum": `{"inputTokens":9223372036854775807,"cachedInputTokens":0,"outputTokens":1,"reasoningOutputTokens":0,"totalTokens":9223372036854775807}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ out := make(chan proto.Envelope, 1)
+ s := &Session{out: out, cancelCtx: t.Context(), cfg: defaultSessionConfig()}
+ s.setThreadID("thread")
+ s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`))
+ s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"turn","tokenUsage":{"total":` + counters + `}}`))
+ if len(s.out) != 1 {
+ t.Fatal("native snapshot was not published")
+ }
+ var usage proto.UsagePayload
+ event := <-out
+ if err := event.DecodePayload(&usage); err != nil || usage.Tokens != nil {
+ t.Fatalf("incomplete native snapshot became public token usage: %+v %v", usage, err)
+ }
+ })
+ }
+}
+
+func TestLiveUsageRejectsInconsistentResumeDelta(t *testing.T) {
+ out := make(chan proto.Envelope, 2)
+ s := &Session{out: out, cancelCtx: t.Context(), cfg: defaultSessionConfig()}
+ s.setThreadID("thread")
+ s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"old","tokenUsage":{"total":{"inputTokens":100,"cachedInputTokens":90,"outputTokens":50,"reasoningOutputTokens":10,"totalTokens":150}}}`))
+ s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn"}}`))
+ // Both thread snapshots are complete, but cache growth exceeds input growth.
+ s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"turn","tokenUsage":{"total":{"inputTokens":110,"cachedInputTokens":105,"outputTokens":55,"reasoningOutputTokens":11,"totalTokens":165}}}`))
+ var usage proto.UsagePayload
+ event := <-out
+ if err := event.DecodePayload(&usage); err != nil || usage.Tokens != nil {
+ t.Fatalf("inconsistent per-Turn delta became public token usage: %+v %v", usage, err)
+ }
+ s.onTurnCompleted(json.RawMessage(`{"threadId":"thread","turn":{"id":"turn","status":"completed"}}`))
+ previous := *s.latestUsage
+ s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"turn","tokenUsage":{"total":{"inputTokens":130,"cachedInputTokens":105,"outputTokens":60,"reasoningOutputTokens":12,"totalTokens":190}}}`))
+ if *s.latestUsage != previous {
+ t.Fatal("late usage changed the completed Turn")
+ }
+}
diff --git a/apps/parsar-daemon/internal/agent/codex/session_usage_test.go b/apps/parsar-daemon/internal/agent/codex/session_usage_test.go
index 4a01b15c3..d0de87f6b 100644
--- a/apps/parsar-daemon/internal/agent/codex/session_usage_test.go
+++ b/apps/parsar-daemon/internal/agent/codex/session_usage_test.go
@@ -11,7 +11,7 @@ import (
)
func TestNativeTokenUsageAcrossRuns(t *testing.T) {
- out := make(chan proto.Envelope, 4)
+ out := make(chan proto.Envelope, 8)
s := &Session{runID: "run", out: out, cancelCtx: context.Background(),
cfg: sessionConfig{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}}
s.setThreadID("thread")
@@ -62,7 +62,7 @@ func TestNativeTokenUsageAcrossRuns(t *testing.T) {
}
func TestNativeTokenUsageFreshThreadAndIgnoredPayloads(t *testing.T) {
- s := &Session{}
+ s := &Session{out: make(chan proto.Envelope, 8), cancelCtx: t.Context(), cfg: defaultSessionConfig()}
s.setThreadID("thread")
s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"current"}}`))
s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"current","tokenUsage":{"total":{"inputTokens":321,"outputTokens":45}}}`))
@@ -81,7 +81,7 @@ func TestNativeTokenUsageFreshThreadAndIgnoredPayloads(t *testing.T) {
}
func TestLegacyTurnUsagePayload(t *testing.T) {
- s := &Session{}
+ s := &Session{out: make(chan proto.Envelope, 8), cancelCtx: t.Context(), cfg: defaultSessionConfig()}
s.setThreadID("thread")
s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"current"}}`))
s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","usage":{"inputTokens":123,"outputTokens":12}}`))
@@ -95,7 +95,7 @@ func TestLegacyTurnUsagePayload(t *testing.T) {
}
func TestCompleteTokenBreakdownAndCancellation(t *testing.T) {
- s := &Session{}
+ s := &Session{out: make(chan proto.Envelope, 8), cancelCtx: t.Context(), cfg: defaultSessionConfig()}
s.setThreadID("thread")
s.onUsageUpdated(json.RawMessage(`{"threadId":"thread","turnId":"old","tokenUsage":{"total":{"inputTokens":100,"cachedInputTokens":20,"outputTokens":50,"reasoningOutputTokens":10,"totalTokens":150}}}`))
s.onTurnStarted(json.RawMessage(`{"threadId":"thread","turn":{"id":"new"}}`))
From 7d6b981014384704d1594fd49a5c6c659a8968e6 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:35:40 +0800
Subject: [PATCH 3/9] fix(client): align history reads and live execution
projections
---
.../features/sessions/items/ItemRenderers.tsx | 2 +-
.../sessions/items/item-status.test.tsx | 19 ++
packages/agents-client/src/client.ts | 277 ++---------------
.../agents-client/src/history-projection.ts | 278 ++++++++++++++++++
.../src/history-recovery.test.ts | 253 ++++++++++++++++
.../agents-client/src/response-projection.ts | 37 +++
packages/agents-client/src/types.ts | 26 +-
.../agents-client/src/usage-projection.ts | 32 ++
8 files changed, 669 insertions(+), 255 deletions(-)
create mode 100644 apps/web/src/features/sessions/items/item-status.test.tsx
create mode 100644 packages/agents-client/src/history-projection.ts
create mode 100644 packages/agents-client/src/history-recovery.test.ts
create mode 100644 packages/agents-client/src/response-projection.ts
create mode 100644 packages/agents-client/src/usage-projection.ts
diff --git a/apps/web/src/features/sessions/items/ItemRenderers.tsx b/apps/web/src/features/sessions/items/ItemRenderers.tsx
index 775d77d6c..f5a4cb2c9 100644
--- a/apps/web/src/features/sessions/items/ItemRenderers.tsx
+++ b/apps/web/src/features/sessions/items/ItemRenderers.tsx
@@ -104,7 +104,7 @@ function WorkStep({ item }: { item: SessionItem }) {
: undefined;
const patch = supported && item.type === "function_call" && item.name === "apply_patch" ? parseParsarApplyPatch(item.arguments) : null;
const expandable = args !== undefined && args !== null || result !== undefined && result !== null;
- const row = <>{verb}{target}{item.status !== "completed" ? : null}{item.duration_ms ? {formatDuration(item.duration_ms)} : null}{expandable ? : null}>;
+ const row = <>{verb}{target}{item.status != null && item.status !== "completed" ? : null}{item.duration_ms ? {formatDuration(item.duration_ms)} : null}{expandable ? : null}>;
return
{expandable ? : {row}
}{expandable ? {patch ? : {args !== undefined && args !== null ?
: null}{result !== undefined && result !== null ?
: null}
} : null};
}
diff --git a/apps/web/src/features/sessions/items/item-status.test.tsx b/apps/web/src/features/sessions/items/item-status.test.tsx
new file mode 100644
index 000000000..68f808a10
--- /dev/null
+++ b/apps/web/src/features/sessions/items/item-status.test.tsx
@@ -0,0 +1,19 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it } from "vitest";
+import type { SessionItem } from "@agents-core-web/agents-client";
+import { ThreadItems } from "./ItemRenderers";
+
+describe("nullable protocol Item status", () => {
+ it("renders an open trace containing a statusless message and unknown reasoning status", () => {
+ const items: SessionItem[] = [
+ { id: "command", turn_id: "turn", type: "command_execution", status: "in_progress", command: "pwd" },
+ { id: "message", turn_id: "turn", type: "agent_message", sender_agent_id: "child", recipient_agent_id: "root", content: [] },
+ { id: "reasoning", turn_id: "turn", type: "reasoning", status: null, summary: [] },
+ ];
+ const html = renderToStaticMarkup();
+ expect(html).toContain('data-trace-step="message"');
+ expect(html).toContain('data-trace-step="reasoning"');
+ expect(html).not.toContain('title="null"');
+ expect(html).not.toContain('title="undefined"');
+ });
+});
diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts
index d82108782..c491cf554 100644
--- a/packages/agents-client/src/client.ts
+++ b/packages/agents-client/src/client.ts
@@ -1,3 +1,6 @@
+import { exactFields, onlyFields, isRecord, hasOwn, canonicalUuid, isNonnegativeInteger, sameResourceId } from "./response-projection";
+import { projectTokenUsage } from "./usage-projection";
+import { projectAgentTurn, projectSessionItem, projectItemContent, projectHistoryPage, validateHistoryPageOptions } from "./history-projection";
import { createSSEDecoder } from "./sse";
import { projectVaultCredentialAuth, validCredentialURL } from "./vault-credential-auth";
import type {
@@ -25,7 +28,6 @@ import type {
FunctionResultContent,
FunctionResultInput,
InputMessage,
- ItemContent,
ListPage,
PageOptions,
ReadOptions,
@@ -151,8 +153,6 @@ const sourceFileFields = new Set([
]);
const sourceFileDeletedFields = new Set(["id", "object", "deleted"]);
const canonicalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
-const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
-const nilUuid = "00000000-0000-0000-0000-000000000000";
const sourceFileIdPattern = /^file-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const maxSourceFileBytes = 512 * 1024 * 1024;
const maxEnvironmentFileBytes = 50 * 1024 * 1024;
@@ -182,11 +182,6 @@ const agentSnapshotFields = new Set([
const multiAgentFields = new Set(["enabled", "max_concurrent_subagents"]);
const reasoningFields = new Set(["effort", "summary"]);
const textFields = new Set(["format", "verbosity"]);
-const tokenUsageFields = new Set([
- "input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details",
-]);
-const inputTokenDetailsFields = new Set(["cached_tokens"]);
-const outputTokenDetailsFields = new Set(["reasoning_tokens"]);
const sessionStatuses = new Set(["idle", "in_progress", "requires_action", "failed"]);
const serviceTiers = new Set(["auto", "default", "flex", "priority", "fast"]);
const reasoningEfforts = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -200,16 +195,6 @@ const inputImageFields = new Set(["type", "image_url"]);
const maxSessionInputEvents = 64;
const maxSessionInputRequestBytes = 1024 * 1024;
const goWhitespaceOnlyPattern = /^[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]*$/u;
-const turnFields = new Set([
- "id", "agent_id", "session_id", "object", "status", "created_at", "started_at", "completed_at", "error", "usage",
-]);
-const turnErrorFields = new Set(["code", "message"]);
-const sessionItemFields = new Set([
- "id", "turn_id", "type", "status", "role", "phase", "content", "command", "cwd", "duration_ms", "exit_code",
- "name", "call_id", "server_label", "arguments", "output", "error", "action",
-]);
-const itemContentTextFields = new Set(["type", "text"]);
-const itemContentImageFields = new Set(["type", "image_url"]);
const streamErrorFields = new Set(["code", "type", "message"]);
const environmentStateFields = new Set(["id", "type", "status", "error"]);
const snapshotEventFields = new Set(["type", "event_id", "session_id", "session"]);
@@ -229,33 +214,10 @@ const errorEventFields = new Set(["type", "event_id", "session_id", "error"]);
const unsafeUnknownEventFields = new Set([
"session", "turn", "turn_id", "item", "item_id", "output_index", "content_index", "part", "delta", "text", "error", "environment",
]);
-const knownItemTypes = new Set([
- "message", "command_execution", "mcp_call", "function_call", "function_call_output", "web_search_call",
-]);
-const itemStatuses = new Set(["in_progress", "completed", "failed", "incomplete"]);
-const turnStatuses = new Set(["queued", "in_progress", "waiting", "completed", "failed", "cancelled"]);
-
-function exactFields(value: Record, fields: Set): boolean {
- const keys = Object.keys(value);
- return keys.length === fields.size && keys.every((key) => fields.has(key));
-}
-
-function onlyFields(value: Record, fields: Set): boolean {
- return Object.keys(value).every((key) => fields.has(key));
-}
-
-function isRecord(value: unknown): value is Record {
- return value !== null && typeof value === "object" && !Array.isArray(value);
-}
-
function utf8Length(value: string): number {
return new TextEncoder().encode(value).length;
}
-function hasOwn(value: Record, field: string): boolean {
- return Object.prototype.hasOwnProperty.call(value, field);
-}
-
function invalidSessionInputBatch(message = "Invalid Session input event batch."): never {
throw new TypeError(message);
}
@@ -373,12 +335,6 @@ function validMetadata(value: unknown): value is Record {
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
}
-function canonicalUuid(value: unknown): string | null {
- if (typeof value !== "string" || !uuidPattern.test(value)) return null;
- const canonical = value.toLowerCase();
- return canonical === nilUuid ? null : canonical;
-}
-
function sameUuid(value: unknown, expected: string): boolean {
const actual = canonicalUuid(value);
const canonicalExpected = canonicalUuid(expected);
@@ -606,17 +562,6 @@ function invalidSessionResource(message = "Agent Core returned an invalid Sessio
throw new AgentCoreError(message, 502, "invalid_session_resource");
}
-function isNonnegativeInteger(value: unknown): value is number {
- return Number.isSafeInteger(value) && Number(value) >= 0;
-}
-
-function sameResourceId(actual: string, expected: string): boolean {
- if (actual === expected) return true;
- const canonicalActual = canonicalUuid(actual);
- const canonicalExpected = canonicalUuid(expected);
- return canonicalActual !== null && canonicalExpected !== null && canonicalActual === canonicalExpected;
-}
-
function projectAgentSnapshot(value: unknown): AgentSession["agent"] {
if (!isRecord(value) || !exactFields(value, agentSnapshotFields)) return invalidSessionResource();
const multiAgent = value.multi_agent;
@@ -885,29 +830,6 @@ function projectRequiredActions(value: unknown): AgentSession["required_actions"
});
}
-function projectTokenUsage(value: unknown): AgentSession["usage"] {
- if (value === null) return null;
- if (!isRecord(value) || !exactFields(value, tokenUsageFields)) return invalidSessionResource();
- const inputDetails = value.input_tokens_details;
- const outputDetails = value.output_tokens_details;
- if (
- !isNonnegativeInteger(value.input_tokens) ||
- !isNonnegativeInteger(value.output_tokens) ||
- !isNonnegativeInteger(value.total_tokens) ||
- !isRecord(inputDetails) || !exactFields(inputDetails, inputTokenDetailsFields) ||
- !isNonnegativeInteger(inputDetails.cached_tokens) ||
- !isRecord(outputDetails) || !exactFields(outputDetails, outputTokenDetailsFields) ||
- !isNonnegativeInteger(outputDetails.reasoning_tokens)
- ) return invalidSessionResource();
- return {
- input_tokens: value.input_tokens,
- output_tokens: value.output_tokens,
- total_tokens: value.total_tokens,
- input_tokens_details: { cached_tokens: inputDetails.cached_tokens },
- output_tokens_details: { reasoning_tokens: outputDetails.reasoning_tokens },
- };
-}
-
function projectAgentSession(
value: unknown,
expectedVaultIds?: string[],
@@ -972,7 +894,7 @@ function projectAgentSession(
metadata: { ...value.metadata },
required_actions: requiredActions,
vault_ids: [...vaultIds] as string[],
- usage: projectTokenUsage(value.usage),
+ usage: projectTokenUsage(value.usage, invalidSessionResource),
created_at: value.created_at,
last_active_at: value.last_active_at,
};
@@ -993,163 +915,6 @@ function projectStreamError(value: unknown): StreamError {
return { code: value.code, type: value.type, message: value.message };
}
-function projectItemContent(value: unknown): ItemContent {
- if (!isRecord(value) || typeof value.type !== "string") return invalidStreamEvent();
- if (value.type === "input_text" || value.type === "output_text") {
- if (!exactFields(value, itemContentTextFields) || typeof value.text !== "string") {
- return invalidStreamEvent();
- }
- return { type: value.type, text: value.text };
- }
- if (
- value.type !== "input_image" || !exactFields(value, itemContentImageFields) ||
- typeof value.image_url !== "string"
- ) return invalidStreamEvent();
- return { type: "input_image", image_url: value.image_url };
-}
-
-function validOptionalInteger(value: unknown): boolean {
- return value === undefined || value === null || isNonnegativeInteger(value);
-}
-
-function validOptionalSafeInteger(value: unknown): boolean {
- return value === undefined || value === null || Number.isSafeInteger(value);
-}
-
-function projectWebSearchAction(value: unknown): SessionItem["action"] {
- const fields = new Set(["type", "query", "queries", "url", "pattern"]);
- if (
- !isRecord(value) || !onlyFields(value, fields) ||
- (value.type !== "search" && value.type !== "open_page" && value.type !== "find_in_page" && value.type !== "other") ||
- !(value.query === undefined || value.query === null || typeof value.query === "string") ||
- !(value.url === undefined || value.url === null || typeof value.url === "string") ||
- !(value.pattern === undefined || value.pattern === null || typeof value.pattern === "string") ||
- !(value.queries === undefined || (Array.isArray(value.queries) && value.queries.every((entry) => typeof entry === "string")))
- ) return invalidStreamEvent();
- return { ...value } as unknown as SessionItem["action"];
-}
-
-function itemVariantFields(type: string): Set {
- const common = ["id", "turn_id", "type", "status"];
- switch (type) {
- case "message": return new Set([...common, "role", "phase", "content"]);
- case "command_execution": return new Set([...common, "command", "cwd", "duration_ms", "exit_code", "output"]);
- case "mcp_call": return new Set([...common, "server_label", "name", "arguments", "output", "error"]);
- case "function_call": return new Set([...common, "name", "call_id", "arguments"]);
- case "function_call_output": return new Set([...common, "call_id", "output", "error", "duration_ms"]);
- case "web_search_call": return new Set([...common, "action"]);
- default: return new Set(common);
- }
-}
-
-function projectSessionItem(value: unknown): SessionItem {
- if (
- !isRecord(value) || !onlyFields(value, sessionItemFields) ||
- typeof value.id !== "string" || value.id === "" ||
- typeof value.turn_id !== "string" || value.turn_id === "" ||
- typeof value.type !== "string" || !knownItemTypes.has(value.type) ||
- typeof value.status !== "string" || !itemStatuses.has(value.status) ||
- !onlyFields(value, itemVariantFields(value.type))
- ) return invalidStreamEvent();
-
- const projected: Record = {
- id: value.id,
- turn_id: value.turn_id,
- type: value.type,
- status: value.status,
- };
- switch (value.type) {
- case "message":
- if (
- (value.role !== "user" && value.role !== "assistant") || !Array.isArray(value.content) ||
- !(value.phase === undefined || value.phase === "commentary" || value.phase === "final_answer")
- ) return invalidStreamEvent();
- projected.role = value.role;
- projected.content = Array.from(value.content, projectItemContent);
- if (value.phase !== undefined) projected.phase = value.phase;
- break;
- case "command_execution":
- if (
- typeof value.command !== "string" ||
- !(value.cwd === undefined || value.cwd === null || typeof value.cwd === "string") ||
- !validOptionalInteger(value.duration_ms) || !validOptionalSafeInteger(value.exit_code)
- ) return invalidStreamEvent();
- projected.command = value.command;
- for (const field of ["cwd", "duration_ms", "exit_code", "output"] as const) {
- if (hasOwn(value, field)) projected[field] = value[field];
- }
- break;
- case "mcp_call":
- if (
- typeof value.server_label !== "string" || value.server_label === "" ||
- typeof value.name !== "string" || value.name === "" ||
- !hasOwn(value, "arguments") || !hasOwn(value, "output") || !hasOwn(value, "error")
- ) return invalidStreamEvent();
- projected.server_label = value.server_label;
- projected.name = value.name;
- projected.arguments = value.arguments;
- projected.output = value.output;
- projected.error = value.error;
- break;
- case "function_call":
- if (
- typeof value.call_id !== "string" || value.call_id === "" ||
- typeof value.name !== "string" || value.name === "" || !hasOwn(value, "arguments")
- ) return invalidStreamEvent();
- projected.call_id = value.call_id;
- projected.name = value.name;
- projected.arguments = value.arguments;
- break;
- case "function_call_output":
- if (typeof value.call_id !== "string" || value.call_id === "" || !validOptionalInteger(value.duration_ms)) {
- return invalidStreamEvent();
- }
- projected.call_id = value.call_id;
- for (const field of ["output", "error", "duration_ms"] as const) {
- if (hasOwn(value, field)) projected[field] = value[field];
- }
- break;
- case "web_search_call":
- // Parsar omits action when the runtime has not reported one yet.
- if (hasOwn(value, "action")) projected.action = projectWebSearchAction(value.action);
- break;
- }
- return projected as unknown as SessionItem;
-}
-
-function projectAgentTurn(value: unknown, expectedSessionId: string): AgentTurn {
- if (
- !isRecord(value) || !exactFields(value, turnFields) ||
- typeof value.id !== "string" || value.id === "" ||
- typeof value.agent_id !== "string" || value.agent_id === "" ||
- typeof value.session_id !== "string" || !sameResourceId(value.session_id, expectedSessionId) ||
- value.object !== "agent.session.turn" || typeof value.status !== "string" || !turnStatuses.has(value.status) ||
- !isNonnegativeInteger(value.created_at) ||
- !(value.started_at === null || isNonnegativeInteger(value.started_at)) ||
- !(value.completed_at === null || isNonnegativeInteger(value.completed_at))
- ) return invalidStreamEvent();
- let error: AgentTurn["error"] = null;
- if (value.error !== null) {
- if (
- !isRecord(value.error) || !exactFields(value.error, turnErrorFields) ||
- value.error.code !== "internal_error" || typeof value.error.message !== "string"
- ) return invalidStreamEvent();
- error = { code: "internal_error", message: value.error.message };
- }
- return {
- id: value.id,
- agent_id: value.agent_id,
- session_id: value.session_id,
- object: "agent.session.turn",
- status: value.status as AgentTurn["status"],
- created_at: value.created_at,
- started_at: value.started_at,
- completed_at: value.completed_at,
- error,
- usage: projectTokenUsage(value.usage),
- };
-}
-
function requiredEventString(event: Record, field: string, allowEmpty = false): string {
const value = event[field];
if (typeof value !== "string" || (!allowEmpty && value === "")) return invalidStreamEvent();
@@ -1190,6 +955,10 @@ function invalidStreamEvent(message = "Agent Core returned an invalid event stre
throw new AgentCoreError(message, 502, "invalid_stream_event");
}
+function invalidHistoryResource(): never {
+ throw new AgentCoreError("Agent Core returned an invalid history resource.", 502, "invalid_history_resource");
+}
+
function parseStreamEvent(message: { event?: string; data: string }): SessionEvent {
let value: unknown;
try {
@@ -1268,10 +1037,12 @@ function projectStreamEventSession(
if (!exactFields(value, turnEventFields) || event.turn === undefined) return invalidStreamEvent();
const sessionId = eventSessionId(value, expectedSessionId, true)!;
const turnId = requiredEventString(value, "turn_id");
- const turn = projectAgentTurn(event.turn, expectedSessionId);
+ const turn = projectAgentTurn(event.turn, expectedSessionId, invalidStreamEvent);
if (
- !sameResourceId(turn.id, turnId) || turn.status !== turnStatusByEvent[event.type] ||
- (immutable !== undefined && !sameResourceId(turn.agent_id, immutable.agent.id))
+ !sameResourceId(turn.id, turnId) ||
+ // Native child history can first appear as a completed created snapshot.
+ (!(event.type === "agent.session.turn.created" && turn.subagent_id != null) && turn.status !== turnStatusByEvent[event.type]) ||
+ (immutable !== undefined && turn.subagent_id == null && !sameResourceId(turn.agent_id, immutable.agent.id))
) return invalidStreamEvent();
return { ...base, session_id: sessionId, turn_id: turnId, turn } as SessionEvent;
}
@@ -1280,7 +1051,7 @@ function projectStreamEventSession(
if (!onlyFields(value, itemEventFields) || event.item === undefined) return invalidStreamEvent();
const sessionId = eventSessionId(value, expectedSessionId, true)!;
const turnId = requiredEventString(value, "turn_id");
- const item = projectSessionItem(event.item);
+ const item = projectSessionItem(event.item, invalidStreamEvent);
if (!sameResourceId(item.turn_id, turnId)) return invalidStreamEvent();
const itemId = value.item_id === undefined ? undefined : requiredEventString(value, "item_id");
if (itemId !== undefined && !sameResourceId(item.id, itemId)) return invalidStreamEvent();
@@ -1302,7 +1073,7 @@ function projectStreamEventSession(
const itemId = requiredEventString(value, "item_id");
const outputIndex = optionalEventIndex(value, "output_index", true)!;
const contentIndex = optionalEventIndex(value, "content_index", true)!;
- const part = projectItemContent(value.part);
+ const part = projectItemContent(value.part, invalidStreamEvent);
return { ...base, session_id: sessionId, turn_id: turnId, item_id: itemId, output_index: outputIndex, content_index: contentIndex, part } as SessionEvent;
}
@@ -2331,26 +2102,32 @@ export class OpenAIAgentsClient implements AgentCore {
return this.request(`/agents/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
}
- listItems(sessionId: string, options?: PageOptions & { signal?: AbortSignal }): Promise> {
+ async listItems(sessionId: string, options?: PageOptions & ReadOptions): Promise> {
+ validateHistoryPageOptions(options);
const params = new URLSearchParams();
addPageOptions(params, options);
- return this.request(withQuery(`/agents/sessions/${encodeURIComponent(sessionId)}/items`, params), {
+ const value = await this.request(withQuery(`/agents/sessions/${encodeURIComponent(sessionId)}/items`, params), {
signal: options?.signal,
});
+ return projectHistoryPage(value, options, (entry) => projectSessionItem(entry, invalidHistoryResource), invalidHistoryResource);
}
- listTurns(sessionId: string, options?: PageOptions & ReadOptions): Promise> {
+ async listTurns(sessionId: string, options?: PageOptions & ReadOptions): Promise> {
+ validateHistoryPageOptions(options);
const params = new URLSearchParams();
addPageOptions(params, options);
- return this.request(withQuery(`/agents/sessions/${encodeURIComponent(sessionId)}/turns`, params), {
+ const value = await this.request(withQuery(`/agents/sessions/${encodeURIComponent(sessionId)}/turns`, params), {
signal: options?.signal,
});
+ return projectHistoryPage(value, options, (entry) => projectAgentTurn(entry, sessionId, invalidHistoryResource), invalidHistoryResource);
}
- retrieveTurn(sessionId: string, turnId: string): Promise {
- return this.request(
+ async retrieveTurn(sessionId: string, turnId: string, options?: ReadOptions): Promise {
+ const value = await this.request(
`/agents/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}`,
+ { signal: options?.signal },
);
+ return projectAgentTurn(value, sessionId, invalidHistoryResource, turnId);
}
submitEvents(
diff --git a/packages/agents-client/src/history-projection.ts b/packages/agents-client/src/history-projection.ts
new file mode 100644
index 000000000..6f05e5672
--- /dev/null
+++ b/packages/agents-client/src/history-projection.ts
@@ -0,0 +1,278 @@
+import type { AgentTurn, ItemContent, SessionItem, ListPage, PageOptions } from "./types";
+import { exactFields, onlyFields, isRecord, hasOwn, isNonnegativeInteger, sameResourceId } from "./response-projection";
+import { projectTokenUsage } from "./usage-projection";
+
+const turnFields = new Set([
+ "id", "agent_id", "session_id", "object", "status", "created_at", "started_at", "completed_at", "error", "usage", "subagent_id",
+]);
+const turnErrorFields = new Set(["code", "message"]);
+const itemContentTextFields = new Set(["type", "text"]);
+const itemContentImageFields = new Set(["type", "image_url"]);
+const knownItemTypes = new Set([
+ "message", "command_execution", "mcp_call", "function_call", "function_call_output", "web_search_call",
+ "reasoning", "agent_message", "create_subagent_call", "send_subagent_input_call", "resume_subagent_call",
+ "wait_for_subagents_call", "interrupt_subagent_call", "close_subagent_call",
+]);
+const itemStatuses = new Set(["in_progress", "completed", "failed", "incomplete"]);
+const turnStatuses = new Set(["queued", "in_progress", "waiting", "completed", "failed", "cancelled"]);
+
+
+export function projectItemContent(value: unknown, invalid: () => never): ItemContent {
+ if (!isRecord(value) || typeof value.type !== "string") return invalid();
+ if (value.type === "input_text" || value.type === "output_text") {
+ if (!exactFields(value, itemContentTextFields) || typeof value.text !== "string") {
+ return invalid();
+ }
+ return { type: value.type, text: value.text };
+ }
+ if (
+ value.type !== "input_image" || !exactFields(value, itemContentImageFields) ||
+ typeof value.image_url !== "string"
+ ) return invalid();
+ return { type: "input_image", image_url: value.image_url };
+}
+
+function validOptionalInteger(value: unknown): boolean {
+ return value === undefined || value === null || isNonnegativeInteger(value);
+}
+
+function validOptionalSafeInteger(value: unknown): boolean {
+ return value === undefined || value === null || Number.isSafeInteger(value);
+}
+
+function projectWebSearchAction(value: unknown, invalid: () => never): SessionItem["action"] {
+ const fields = new Set(["type", "query", "queries", "url", "pattern"]);
+ if (
+ !isRecord(value) || !onlyFields(value, fields) ||
+ (value.type !== "search" && value.type !== "open_page" && value.type !== "find_in_page" && value.type !== "other") ||
+ !(value.query === undefined || value.query === null || typeof value.query === "string") ||
+ !(value.url === undefined || value.url === null || typeof value.url === "string") ||
+ !(value.pattern === undefined || value.pattern === null || typeof value.pattern === "string") ||
+ !(value.queries === undefined || (Array.isArray(value.queries) && value.queries.every((entry) => typeof entry === "string")))
+ ) return invalid();
+ return { ...value } as unknown as SessionItem["action"];
+}
+
+function itemVariantFields(type: string): Set {
+ const common = ["id", "turn_id", "type", "status"];
+ switch (type) {
+ case "message": return new Set([...common, "role", "phase", "content"]);
+ case "command_execution": return new Set([...common, "command", "cwd", "duration_ms", "exit_code", "output"]);
+ case "mcp_call": return new Set([...common, "server_label", "name", "arguments", "output", "error"]);
+ case "function_call": return new Set([...common, "name", "call_id", "arguments"]);
+ case "function_call_output": return new Set([...common, "call_id", "output", "error", "duration_ms"]);
+ case "web_search_call": return new Set([...common, "action"]);
+ case "reasoning": return new Set([...common, "summary"]);
+ case "agent_message": return new Set(["id", "turn_id", "type", "sender_agent_id", "recipient_agent_id", "content"]);
+ case "create_subagent_call": return new Set([...common, "agent_id", "content", "model", "reasoning_effort"]);
+ case "send_subagent_input_call": return new Set([...common, "sender_agent_id", "recipient_agent_id", "content"]);
+ case "wait_for_subagents_call": return new Set([...common, "sender_agent_id", "recipient_agent_ids"]);
+ case "resume_subagent_call":
+ case "interrupt_subagent_call":
+ case "close_subagent_call": return new Set([...common, "sender_agent_id", "recipient_agent_id"]);
+ default: return new Set(common);
+ }
+}
+
+export function projectSessionItem(value: unknown, invalid: () => never): SessionItem {
+ if (
+ !isRecord(value) ||
+ typeof value.id !== "string" || value.id === "" ||
+ typeof value.turn_id !== "string" || value.turn_id === "" ||
+ typeof value.type !== "string" || !knownItemTypes.has(value.type) ||
+ !onlyFields(value, itemVariantFields(value.type))
+ ) return invalid();
+ if (value.type !== "agent_message" && !(
+ (value.type === "reasoning" && (value.status === undefined || value.status === null)) ||
+ (typeof value.status === "string" && itemStatuses.has(value.status) && !(value.type === "reasoning" && value.status === "failed"))
+ )) return invalid();
+
+ const projected: Record = {
+ id: value.id,
+ turn_id: value.turn_id,
+ type: value.type,
+ ...(hasOwn(value, "status") ? { status: value.status } : {}),
+ };
+ switch (value.type) {
+ case "message":
+ if (
+ (value.role !== "user" && value.role !== "assistant") || !Array.isArray(value.content) ||
+ !(value.phase === undefined || value.phase === "commentary" || value.phase === "final_answer")
+ ) return invalid();
+ projected.role = value.role;
+ projected.content = Array.from(value.content, (part) => projectItemContent(part, invalid));
+ if (value.phase !== undefined) projected.phase = value.phase;
+ break;
+ case "command_execution":
+ if (
+ typeof value.command !== "string" ||
+ !(value.cwd === undefined || value.cwd === null || typeof value.cwd === "string") ||
+ !validOptionalInteger(value.duration_ms) || !validOptionalSafeInteger(value.exit_code)
+ ) return invalid();
+ projected.command = value.command;
+ for (const field of ["cwd", "duration_ms", "exit_code", "output"] as const) {
+ if (hasOwn(value, field)) projected[field] = value[field];
+ }
+ break;
+ case "mcp_call":
+ if (
+ typeof value.server_label !== "string" || value.server_label === "" ||
+ typeof value.name !== "string" || value.name === "" ||
+ !hasOwn(value, "arguments") || !hasOwn(value, "output") || !hasOwn(value, "error")
+ ) return invalid();
+ projected.server_label = value.server_label;
+ projected.name = value.name;
+ projected.arguments = value.arguments;
+ projected.output = value.output;
+ projected.error = value.error;
+ break;
+ case "function_call":
+ if (
+ typeof value.call_id !== "string" || value.call_id === "" ||
+ typeof value.name !== "string" || value.name === "" || !hasOwn(value, "arguments")
+ ) return invalid();
+ projected.call_id = value.call_id;
+ projected.name = value.name;
+ projected.arguments = value.arguments;
+ break;
+ case "function_call_output":
+ if (typeof value.call_id !== "string" || value.call_id === "" || !validOptionalInteger(value.duration_ms)) {
+ return invalid();
+ }
+ projected.call_id = value.call_id;
+ for (const field of ["output", "error", "duration_ms"] as const) {
+ if (hasOwn(value, field)) projected[field] = value[field];
+ }
+ break;
+ case "web_search_call":
+ // Parsar omits action when the runtime has not reported one yet.
+ if (hasOwn(value, "action")) projected.action = projectWebSearchAction(value.action, invalid);
+ break;
+ case "reasoning":
+ if (!Array.isArray(value.summary)) return invalid();
+ projected.summary = value.summary.map((part) => {
+ if (!isRecord(part) || !exactFields(part, new Set(["type", "text"])) || part.type !== "summary_text" || typeof part.text !== "string") return invalid();
+ return { type: "summary_text", text: part.text };
+ });
+ break;
+ case "create_subagent_call":
+ if (!nonemptyString(value.agent_id) || !optionalString(value.model) || !optionalString(value.reasoning_effort)) return invalid();
+ projected.agent_id = value.agent_id;
+ projected.content = projectCoordinationContent(value.content, invalid);
+ for (const field of ["model", "reasoning_effort"]) {
+ if (hasOwn(value, field)) projected[field] = value[field];
+ }
+ break;
+ case "agent_message":
+ case "send_subagent_input_call":
+ case "resume_subagent_call":
+ case "interrupt_subagent_call":
+ case "close_subagent_call":
+ if (!nonemptyString(value.sender_agent_id) || !nonemptyString(value.recipient_agent_id)) return invalid();
+ projected.sender_agent_id = value.sender_agent_id;
+ projected.recipient_agent_id = value.recipient_agent_id;
+ if (value.type === "agent_message" || value.type === "send_subagent_input_call") {
+ projected.content = projectCoordinationContent(value.content, invalid);
+ }
+ break;
+ case "wait_for_subagents_call":
+ if (!nonemptyString(value.sender_agent_id) || !Array.isArray(value.recipient_agent_ids) || !value.recipient_agent_ids.every(nonemptyString)) return invalid();
+ projected.sender_agent_id = value.sender_agent_id;
+ projected.recipient_agent_ids = [...value.recipient_agent_ids];
+ break;
+ }
+ return projected as unknown as SessionItem;
+}
+
+export function projectAgentTurn(value: unknown, expectedSessionId: string, invalid: () => never, expectedTurnId?: string): AgentTurn {
+ if (
+ !isRecord(value) || !onlyFields(value, turnFields) ||
+ typeof value.id !== "string" || value.id === "" ||
+ (expectedTurnId !== undefined && !sameResourceId(value.id, expectedTurnId)) ||
+ typeof value.agent_id !== "string" || value.agent_id === "" ||
+ !(value.subagent_id === undefined || value.subagent_id === null || (nonemptyString(value.subagent_id) && sameResourceId(value.subagent_id, value.agent_id))) ||
+ typeof value.session_id !== "string" || !sameResourceId(value.session_id, expectedSessionId) ||
+ value.object !== "agent.session.turn" || typeof value.status !== "string" || !turnStatuses.has(value.status) ||
+ !isNonnegativeInteger(value.created_at) ||
+ !(value.started_at === null || isNonnegativeInteger(value.started_at)) ||
+ !(value.completed_at === null || isNonnegativeInteger(value.completed_at))
+ ) return invalid();
+ let error: AgentTurn["error"] = null;
+ if (value.error !== null) {
+ if (
+ !isRecord(value.error) || !exactFields(value.error, turnErrorFields) ||
+ value.error.code !== "internal_error" || typeof value.error.message !== "string"
+ ) return invalid();
+ error = { code: "internal_error", message: value.error.message };
+ }
+ return {
+ id: value.id,
+ agent_id: value.agent_id,
+ ...(hasOwn(value, "subagent_id") ? { subagent_id: value.subagent_id as string | null } : {}),
+ session_id: value.session_id,
+ object: "agent.session.turn",
+ status: value.status as AgentTurn["status"],
+ created_at: value.created_at,
+ started_at: value.started_at,
+ completed_at: value.completed_at,
+ error,
+ usage: projectTokenUsage(value.usage, invalid),
+ };
+}
+
+function nonemptyString(value: unknown): value is string {
+ return typeof value === "string" && value !== "";
+}
+
+function optionalString(value: unknown): boolean {
+ return value === undefined || value === null || typeof value === "string";
+}
+
+function projectCoordinationContent(value: unknown, invalid: () => never): ItemContent[] {
+ if (!Array.isArray(value)) return invalid();
+ return value.map((part) => {
+ if (!isRecord(part)) return invalid();
+ if (part.type === "output_text") return projectItemContent(part, invalid);
+ if (part.type !== "encrypted_content" || !exactFields(part, new Set(["type", "encrypted_content"])) || typeof part.encrypted_content !== "string") return invalid();
+ return { type: "encrypted_content", encrypted_content: part.encrypted_content };
+ });
+}
+
+export function validateHistoryPageOptions(options?: PageOptions): void {
+ if (options?.limit !== undefined && (!Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100)) {
+ throw new TypeError("History list limit must be an integer from 1 through 100.");
+ }
+ if (options?.order !== undefined && options.order !== "asc" && options.order !== "desc") {
+ throw new TypeError("History list order must be asc or desc.");
+ }
+ if (options?.after !== undefined && !nonemptyString(options.after)) {
+ throw new TypeError("History list cursor must be a nonempty resource ID.");
+ }
+}
+
+export function projectHistoryPage(
+ value: unknown,
+ options: PageOptions | undefined,
+ project: (entry: unknown) => T,
+ invalid: () => never,
+): ListPage {
+ if (!isRecord(value) || !onlyFields(value, new Set(["data", "has_more", "object", "first_id", "last_id"])) ||
+ !Array.isArray(value.data) || typeof value.has_more !== "boolean" ||
+ value.data.length > (options?.limit ?? 20) || (value.has_more && value.data.length === 0) ||
+ (hasOwn(value, "object") && value.object !== "list")
+ ) return invalid();
+ const data = value.data.map(project);
+ if (data.some((entry, index) =>
+ (options?.after !== undefined && sameResourceId(entry.id, options.after)) ||
+ data.slice(0, index).some((previous) => sameResourceId(previous.id, entry.id))
+ )) return invalid();
+ const first = data[0]?.id ?? null;
+ const last = data[data.length - 1]?.id ?? null;
+ if ((hasOwn(value, "first_id") && value.first_id !== first) || (hasOwn(value, "last_id") && value.last_id !== last)) return invalid();
+ return {
+ data, has_more: value.has_more,
+ ...(hasOwn(value, "object") ? { object: "list" as const } : {}),
+ ...(hasOwn(value, "first_id") ? { first_id: first } : {}),
+ ...(hasOwn(value, "last_id") ? { last_id: last } : {}),
+ };
+}
diff --git a/packages/agents-client/src/history-recovery.test.ts b/packages/agents-client/src/history-recovery.test.ts
new file mode 100644
index 000000000..b56a44fac
--- /dev/null
+++ b/packages/agents-client/src/history-recovery.test.ts
@@ -0,0 +1,253 @@
+import { describe, expect, it, vi } from "vitest";
+import { OpenAIAgentsClient } from "./client";
+import type { AgentTurn, PageOptions, SessionEvent } from "./types";
+
+const usage = {
+ input_tokens: 7226, output_tokens: 7, total_tokens: 7233,
+ input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 },
+};
+
+function turn(overrides: Record = {}) {
+ return {
+ id: "turn", agent_id: "root", subagent_id: null, session_id: "session",
+ object: "agent.session.turn", status: "completed", created_at: 1,
+ started_at: 1, completed_at: 2, error: null, usage: null, ...overrides,
+ };
+}
+
+function session() {
+ return {
+ id: "session", object: "agent.session", status: "idle", error: null,
+ metadata: {}, required_actions: [], vault_ids: [], usage: null, created_at: 1, last_active_at: 1,
+ environment: { type: "none" },
+ agent: {
+ id: "root", model: "model", name: null, instructions: null,
+ multi_agent: { enabled: true, max_concurrent_subagents: null }, reasoning: {},
+ service_tier: "auto", text: { format: { type: "text" }, verbosity: "medium" }, tools: [],
+ },
+ };
+}
+
+function json(value: unknown) {
+ return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json" } });
+}
+
+function sse(events: unknown[]) {
+ return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
+ headers: { "Content-Type": "text/event-stream" },
+ });
+}
+
+function turnEvent(value = turn(), type = "agent.session.turn.completed") {
+ return { type, event_id: "event", session_id: "session", turn_id: value.id, turn: value };
+}
+
+function itemEvent(item: Record) {
+ return {
+ type: "agent.session.turn.item.added", event_id: `event-${item.id}`, session_id: "session",
+ turn_id: item.turn_id, output_index: 0, item,
+ };
+}
+
+// Exact wire examples also exercised by contracts/agents-api/v1/subagents_test.go.
+// The pinned SDK is openai-python d7c41ef, types/beta/agent_session_item.py.
+const items: Record[] = [
+ { id: "create", turn_id: "turn", type: "create_subagent_call", status: "completed", agent_id: "root", content: [], model: null, reasoning_effort: null },
+ { id: "create-model", turn_id: "turn", type: "create_subagent_call", status: "failed", agent_id: "root", content: [{ type: "output_text", text: "" }], model: "requested-model", reasoning_effort: "high" },
+ { id: "send", turn_id: "turn", type: "send_subagent_input_call", status: "completed", sender_agent_id: "root", recipient_agent_id: "child", content: [{ type: "encrypted_content", encrypted_content: "opaque" }] },
+ { id: "wait", turn_id: "turn", type: "wait_for_subagents_call", status: "in_progress", sender_agent_id: "root", recipient_agent_ids: ["child", "other"] },
+ { id: "resume", turn_id: "turn", type: "resume_subagent_call", status: "completed", sender_agent_id: "root", recipient_agent_id: "child" },
+ { id: "interrupt", turn_id: "turn", type: "interrupt_subagent_call", status: "incomplete", sender_agent_id: "root", recipient_agent_id: "child" },
+ { id: "close", turn_id: "turn", type: "close_subagent_call", status: "failed", sender_agent_id: "root", recipient_agent_id: "child" },
+ { id: "message", turn_id: "turn", type: "agent_message", sender_agent_id: "child", recipient_agent_id: "root", content: [{ type: "output_text", text: "Result." }] },
+ { id: "reasoning", turn_id: "turn", type: "reasoning", status: null, summary: [] },
+ { id: "reasoning-completed", turn_id: "turn", type: "reasoning", status: "completed", summary: [{ type: "summary_text", text: "Checked." }] },
+];
+
+describe("history and live event projections", () => {
+ it.each([null, "child"])("preserves %s subagent identity in reads and SSE", async (subagentId) => {
+ const value = turn({ agent_id: subagentId ?? "root", subagent_id: subagentId, usage });
+ const client = new OpenAIAgentsClient({ fetch: vi.fn()
+ .mockResolvedValueOnce(json(value))
+ .mockResolvedValueOnce(json({ data: [value], has_more: false }))
+ .mockResolvedValueOnce(sse([turnEvent(value)])) });
+ expect(await client.retrieveTurn("session", "turn")).toEqual(value);
+ expect((await client.listTurns("session")).data).toEqual([value]);
+ const received: SessionEvent[] = [];
+ await client.streamEvents("session", { onEvent: (event) => received.push(event) });
+ expect(received[0]?.turn).toEqual(value);
+ });
+
+ it("accepts the pinned optional subagent_id omission without inventing an identity", async () => {
+ const { subagent_id: _omitted, ...value } = turn();
+ const client = new OpenAIAgentsClient({ fetch: async () => json(value) });
+ expect(await client.retrieveTurn("session", "turn")).toEqual(value);
+ });
+
+ it("retains completed child snapshots first observed in a creation stream", async () => {
+ const value = turn({ agent_id: "child", subagent_id: "child" });
+ const created = { type: "agent.session.created", event_id: "created", session: session() };
+ const client = new OpenAIAgentsClient({ fetch: async () => sse([
+ created, turnEvent(value, "agent.session.turn.created"), turnEvent(value),
+ ]) });
+ const received: SessionEvent[] = [];
+ await client.createSessionStream({ environment: { type: "none" } }, "key", {
+ onSession: () => undefined, onEvent: (event) => received.push(event),
+ });
+ expect(received.map((event) => event.turn).filter(Boolean)).toEqual([value, value]);
+ });
+
+ it("does not weaken root creation snapshots or immutable root identity", async () => {
+ for (const value of [turn(), turn({ status: "queued", agent_id: "other" })]) {
+ const client = new OpenAIAgentsClient({ fetch: async () => sse([
+ { type: "agent.session.created", event_id: "created", session: session() },
+ turnEvent(value, "agent.session.turn.created"),
+ ]) });
+ await expect(client.createSessionStream({ environment: { type: "none" } }, "key", {
+ onSession: () => undefined, onEvent: () => undefined,
+ })).rejects.toMatchObject({ code: "invalid_stream_event" });
+ }
+ });
+
+ it("projects supported coordination/reasoning Items identically through history and SSE", async () => {
+ const client = new OpenAIAgentsClient({ fetch: vi.fn()
+ .mockResolvedValueOnce(json({ data: items, has_more: false }))
+ .mockResolvedValueOnce(sse(items.map(itemEvent))) });
+ const saved = (await client.listItems("session")).data;
+ const live: SessionEvent[] = [];
+ await client.streamEvents("session", { onEvent: (event) => live.push(event) });
+ expect(saved).toEqual(items);
+ expect(live.map((event) => event.item)).toEqual(saved);
+ expect(saved.find((item) => item.type === "agent_message")).not.toHaveProperty("status");
+ expect(saved.find((item) => item.id === "reasoning")?.status).toBeNull();
+ });
+
+ it.each([
+ { ...items[0], agent_id: "" },
+ { ...items[2], content: [{ type: "encrypted_content", text: "wrong" }] },
+ { ...items[3], recipient_agent_ids: ["child", 123] },
+ { ...items[7], status: "completed" },
+ { ...items[8], status: "failed" },
+ { ...items[9], summary: [{ type: "summary_text", text: 123 }] },
+ { ...items[0], native_session_id: "private" },
+ ])("rejects malformed or private Item fields in both paths: $type", async (item) => {
+ const onEvent = vi.fn();
+ const client = new OpenAIAgentsClient({ fetch: vi.fn()
+ .mockResolvedValueOnce(json({ data: [item], has_more: false }))
+ .mockResolvedValueOnce(sse([itemEvent(item)])) });
+ await expect(client.listItems("session")).rejects.toMatchObject({ code: "invalid_history_resource" });
+ await expect(client.streamEvents("session", { onEvent })).rejects.toMatchObject({ code: "invalid_stream_event" });
+ expect(onEvent).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ turn({ session_id: "foreign" }), turn({ id: "other" }),
+ turn({ agent_id: "root", subagent_id: "child" }), turn({ subagent_id: "" }),
+ turn({ usage: { input_tokens: 1 } }), turn({ native_session_id: "private" }),
+ ])("rejects malformed or mismatched Turn retrieval", async (value) => {
+ const client = new OpenAIAgentsClient({ fetch: async () => json(value) });
+ await expect(client.retrieveTurn("session", "turn")).rejects.toMatchObject({ code: "invalid_history_resource" });
+ });
+
+ it("preserves a caller's cancellation signal on Turn retrieval", async () => {
+ const controller = new AbortController();
+ const fetch = vi.fn(async () => json(turn()));
+ const client = new OpenAIAgentsClient({ fetch });
+ await client.retrieveTurn("session", "turn", { signal: controller.signal });
+ expect(fetch).toHaveBeenCalledWith(expect.stringContaining("/sessions/session/turns/turn"), expect.objectContaining({ signal: controller.signal }));
+ });
+
+ it("recovers measured usage after a completed event with unknown usage", async () => {
+ const completed = turn();
+ const measured = turn({ usage });
+ const requests: string[] = [];
+ const responses = [sse([turnEvent(completed)]), sse([turnEvent(measured)]), json(measured), json({ data: [measured], has_more: false })];
+ const client = new OpenAIAgentsClient({ fetch: async (input, init) => {
+ requests.push(String(input));
+ expect(new Headers(init?.headers).has("Last-Event-ID")).toBe(false);
+ return responses.shift()!;
+ } });
+ const observed: AgentTurn[] = [];
+ await client.streamEvents("session", { onEvent: (event) => { if (event.turn) observed.push(event.turn); } });
+ expect(observed[0]?.usage).toBeNull();
+ let opened = false;
+ await client.streamEvents("session", {
+ onOpen: () => { opened = true; },
+ onEvent: (event) => { expect(opened).toBe(true); if (event.turn) observed.push(event.turn); },
+ });
+ expect((await client.retrieveTurn("session", "turn")).usage).toEqual(usage);
+ expect((await client.listTurns("session")).data[0]?.usage).toEqual(usage);
+ expect(observed.map((entry) => entry.usage)).toEqual([null, usage]);
+ expect(requests).toHaveLength(4);
+ });
+});
+
+describe("history pagination", () => {
+ it.each(["asc", "desc"] as const)("recovers every saved Item across %s pages without changing snapshots", async (order) => {
+ const snapshots = [
+ { id: "input", turn_id: "turn", type: "message", status: "completed", role: "user", content: [{ type: "input_text", text: "Question" }] },
+ { id: "answer", turn_id: "turn", type: "message", status: "incomplete", role: "assistant", phase: "commentary", content: [{ type: "output_text", text: "Retained partial answer" }] },
+ { id: "command", turn_id: "turn", type: "command_execution", status: "incomplete", command: "work", output: "Retained output", exit_code: null },
+ ];
+ const expected = order === "asc" ? snapshots : [...snapshots].reverse();
+ const client = new OpenAIAgentsClient({ fetch: async (input) => {
+ const query = new URL(String(input), "http://core.test").searchParams;
+ expect(query.get("order")).toBe(order);
+ const after = query.get("after");
+ const index = after === null ? 0 : expected.findIndex((item) => item.id === after) + 1;
+ return json({ data: expected.slice(index, index + 1), has_more: index + 1 < expected.length });
+ } });
+ const recovered = [];
+ let after: string | undefined;
+ for (;;) {
+ const page = await client.listItems("session", { order, limit: 1, after });
+ recovered.push(...page.data);
+ if (!page.has_more) break;
+ after = page.data[page.data.length - 1]!.id;
+ }
+ expect(recovered).toEqual(expected);
+ });
+
+ it.each(["asc", "desc"] as const)("keeps the %s cursor and optional page metadata", async (order) => {
+ const calls: string[] = [];
+ const client = new OpenAIAgentsClient({ fetch: async (input) => {
+ calls.push(String(input));
+ return json({ data: [turn()], has_more: true, object: "list", first_id: "turn", last_id: "turn" });
+ } });
+ expect(await client.listTurns("session", { after: "previous/turn", limit: 1, order })).toEqual({
+ data: [turn()], has_more: true, object: "list", first_id: "turn", last_id: "turn",
+ });
+ expect(calls[0]).toContain(`after=previous%2Fturn&limit=1&order=${order}`);
+ });
+
+ it.each([
+ {}, { data: [], has_more: true }, { data: [], has_more: "false" },
+ { data: [turn(), turn()], has_more: false },
+ { data: [turn()], has_more: false, first_id: "wrong" },
+ { data: [turn()], has_more: false, last_id: null },
+ { data: [], has_more: false, object: "turn.list" },
+ { data: [turn({ session_id: "foreign" })], has_more: false },
+ { data: [turn()], has_more: false, internal_cursor: "private" },
+ ])("rejects malformed Turn pages", async (page) => {
+ const client = new OpenAIAgentsClient({ fetch: async () => json(page) });
+ await expect(client.listTurns("session")).rejects.toMatchObject({ code: "invalid_history_resource" });
+ });
+
+ it("rejects cursor repetition, overfull pages and invalid Item envelopes", async () => {
+ const client = new OpenAIAgentsClient({ fetch: vi.fn()
+ .mockResolvedValueOnce(json({ data: [turn()], has_more: true }))
+ .mockResolvedValueOnce(json({ data: [turn(), turn({ id: "turn-2" })], has_more: false }))
+ .mockResolvedValueOnce(json({ data: [items[0]], has_more: false, last_id: "other" })) });
+ await expect(client.listTurns("session", { after: "turn" })).rejects.toMatchObject({ code: "invalid_history_resource" });
+ await expect(client.listTurns("session", { limit: 1 })).rejects.toMatchObject({ code: "invalid_history_resource" });
+ await expect(client.listItems("session")).rejects.toMatchObject({ code: "invalid_history_resource" });
+ });
+
+ it.each([{ limit: 0 }, { limit: 101 }, { limit: 1.5 }, { order: "newest" }, { after: "" }])("rejects invalid options before HTTP", async (options) => {
+ const fetch = vi.fn();
+ const client = new OpenAIAgentsClient({ fetch });
+ await expect(client.listItems("session", options as PageOptions)).rejects.toBeInstanceOf(TypeError);
+ await expect(client.listTurns("session", options as PageOptions)).rejects.toBeInstanceOf(TypeError);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/agents-client/src/response-projection.ts b/packages/agents-client/src/response-projection.ts
new file mode 100644
index 000000000..8387ed978
--- /dev/null
+++ b/packages/agents-client/src/response-projection.ts
@@ -0,0 +1,37 @@
+const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const nilUuid = "00000000-0000-0000-0000-000000000000";
+
+export function exactFields(value: Record, fields: Set): boolean {
+ const keys = Object.keys(value);
+ return keys.length === fields.size && keys.every((key) => fields.has(key));
+}
+
+export function onlyFields(value: Record, fields: Set): boolean {
+ return Object.keys(value).every((key) => fields.has(key));
+}
+
+export function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+export function hasOwn(value: Record, field: string): boolean {
+ return Object.prototype.hasOwnProperty.call(value, field);
+}
+
+export function canonicalUuid(value: unknown): string | null {
+ if (typeof value !== "string" || !uuidPattern.test(value)) return null;
+ const canonical = value.toLowerCase();
+ return canonical === nilUuid ? null : canonical;
+}
+
+export function isNonnegativeInteger(value: unknown): value is number {
+ return Number.isSafeInteger(value) && Number(value) >= 0;
+}
+
+export function sameResourceId(actual: string, expected: string): boolean {
+ if (actual === expected) return true;
+ const canonicalActual = canonicalUuid(actual);
+ const canonicalExpected = canonicalUuid(expected);
+ return canonicalActual !== null && canonicalExpected !== null && canonicalActual === canonicalExpected;
+}
+
diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts
index 6026b7e29..ca2ab73f7 100644
--- a/packages/agents-client/src/types.ts
+++ b/packages/agents-client/src/types.ts
@@ -494,9 +494,10 @@ export interface InputMessage {
export type ItemStatus = "in_progress" | "completed" | "failed" | "incomplete";
export interface ItemContent {
- type: "input_text" | "output_text" | "input_image";
+ type: "input_text" | "output_text" | "input_image" | "encrypted_content";
text?: string | null;
image_url?: string;
+ encrypted_content?: string;
}
export type KnownSessionItemType =
@@ -505,14 +506,23 @@ export type KnownSessionItemType =
| "mcp_call"
| "function_call"
| "function_call_output"
- | "web_search_call";
+ | "web_search_call"
+ | "reasoning"
+ | "agent_message"
+ | "create_subagent_call"
+ | "send_subagent_input_call"
+ | "resume_subagent_call"
+ | "wait_for_subagents_call"
+ | "interrupt_subagent_call"
+ | "close_subagent_call";
export type UnknownSessionItemType = string & { readonly [unknownItemType]: true };
export interface SessionItemBase {
id: string;
turn_id: string;
- status: ItemStatus;
+ /** Inter-agent messages have no status; reasoning may have an unknown status. */
+ status?: ItemStatus | null;
role?: "user" | "assistant";
phase?: "commentary" | "final_answer";
content?: ItemContent[];
@@ -527,6 +537,13 @@ export interface SessionItemBase {
output?: unknown;
error?: unknown;
action?: WebSearchAction;
+ agent_id?: string;
+ sender_agent_id?: string;
+ recipient_agent_id?: string;
+ recipient_agent_ids?: string[];
+ model?: string | null;
+ reasoning_effort?: string | null;
+ summary?: { type: "summary_text"; text: string }[];
}
export interface KnownSessionItem extends SessionItemBase {
@@ -553,6 +570,7 @@ export type TurnStatus = "queued" | "in_progress" | "waiting" | "completed" | "f
export interface AgentTurn {
id: string;
agent_id: string;
+ subagent_id?: string | null;
session_id: string;
object: "agent.session.turn";
status: TurnStatus;
@@ -736,7 +754,7 @@ export interface AgentCore {
deleteSession(sessionId: string): Promise;
listItems(sessionId: string, options?: PageOptions & ReadOptions): Promise>;
listTurns(sessionId: string, options?: PageOptions & ReadOptions): Promise>;
- retrieveTurn(sessionId: string, turnId: string): Promise;
+ retrieveTurn(sessionId: string, turnId: string, options?: ReadOptions): Promise;
submitEvents(sessionId: string, events: readonly SessionInputEvent[], idempotencyKey: string): Promise;
sendMessage(sessionId: string, text: string, idempotencyKey: string): Promise;
cancelTurn(sessionId: string, idempotencyKey: string): Promise;
diff --git a/packages/agents-client/src/usage-projection.ts b/packages/agents-client/src/usage-projection.ts
new file mode 100644
index 000000000..a672a1463
--- /dev/null
+++ b/packages/agents-client/src/usage-projection.ts
@@ -0,0 +1,32 @@
+import type { AgentSession } from "./types";
+import { isRecord, exactFields, isNonnegativeInteger } from "./response-projection";
+
+const tokenUsageFields = new Set([
+ "input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details",
+]);
+const inputTokenDetailsFields = new Set(["cached_tokens"]);
+const outputTokenDetailsFields = new Set(["reasoning_tokens"]);
+
+export function projectTokenUsage(value: unknown, invalid: () => never): AgentSession["usage"] {
+ if (value === null) return null;
+ if (!isRecord(value) || !exactFields(value, tokenUsageFields)) return invalid();
+ const inputDetails = value.input_tokens_details;
+ const outputDetails = value.output_tokens_details;
+ if (
+ !isNonnegativeInteger(value.input_tokens) ||
+ !isNonnegativeInteger(value.output_tokens) ||
+ !isNonnegativeInteger(value.total_tokens) ||
+ !isRecord(inputDetails) || !exactFields(inputDetails, inputTokenDetailsFields) ||
+ !isNonnegativeInteger(inputDetails.cached_tokens) ||
+ !isRecord(outputDetails) || !exactFields(outputDetails, outputTokenDetailsFields) ||
+ !isNonnegativeInteger(outputDetails.reasoning_tokens)
+ ) return invalid();
+ return {
+ input_tokens: value.input_tokens,
+ output_tokens: value.output_tokens,
+ total_tokens: value.total_tokens,
+ input_tokens_details: { cached_tokens: inputDetails.cached_tokens },
+ output_tokens_details: { reasoning_tokens: outputDetails.reasoning_tokens },
+ };
+}
+
From 3d78c7494411b31c77f5dc47f8736472fba392cf Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:39:32 +0800
Subject: [PATCH 4/9] docs: define history recovery and measured usage
boundaries
---
contracts/agents-api/README.md | 8 +-
contracts/agents-api/history-events-usage.md | 84 ++++++++++++++++++++
2 files changed, 91 insertions(+), 1 deletion(-)
create mode 100644 contracts/agents-api/history-events-usage.md
diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md
index 05fe8d34f..9cfad97fe 100644
--- a/contracts/agents-api/README.md
+++ b/contracts/agents-api/README.md
@@ -589,8 +589,14 @@ HTTP handler, PostgreSQL, daemon and Codex with a synthetic model provider.
Token measurements use the pinned SDK's `TokenUsage` fields. The optional daemon
`usage.tokens` supplies complete per-Turn counters; journal and terminal writes
replace that Turn's snapshot atomically. Repeated snapshots do not increase totals.
+Codex publishes observed active-Turn snapshots before completion through this same
+contract. Persisted measurements remain available after cancellation or worker
+restart; measurements never received by Core cannot be recovered this way.
Unknown historical breakdowns are not backfilled, and a Session total includes only
-recorded measurements. Costs and prices are outside this execution contract.
+recorded root-Turn measurements. Child Turns returned in a mixed history page are
+not an additional accounting ledger. Costs and prices are outside this execution
+contract. See [history, events and usage](history-events-usage.md) for client
+recovery rules, native measurement limits and bounded official-service evidence.
### Live events
diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md
new file mode 100644
index 000000000..ba29e62cd
--- /dev/null
+++ b/contracts/agents-api/history-events-usage.md
@@ -0,0 +1,84 @@
+# History, events and usage
+
+This milestone covers existing execution resources and client recovery. It does
+not establish complete Agents API compatibility. The protocol baseline remains
+the SDK and source recorded in [upstream.json](upstream.json).
+
+## Query and live-stream contract
+
+Open the live Session events stream before submitting input. After disconnection,
+subscribe again and retrieve Session, Turns and Items to recover persisted state.
+An SSE connection is an observer, not the owner of execution. `Last-Event-ID` does
+not introduce replay. Query responses and transition events are observations at
+different times; a completed Turn can acquire a measured usage snapshot later.
+Do not permanently cache `usage: null` as zero or as a final accounting result.
+
+The TypeScript client shares Turn and Item projections between reads and SSE.
+It preserves root/child identity and supports the currently implemented reasoning
+and coordination Items. `agent_message` has no status; reasoning status can be
+absent or null. Child discovery can publish an already terminal native Turn in a
+`turn.created` snapshot; clients must not reinterpret it as newly queued work.
+This does not widen the server's Item or interim reasoning-event coverage.
+
+Use response cursors to page history in the requested direction. Session Turn
+lists can include root and child Turns. Root Items and child Items have separate
+query resources; use the Subagent resources for child history. Tenant ownership
+is enforced by Core for both queries and streams.
+
+## Measurement boundary
+
+Adapters publish cumulative measurements for the current execution through the
+existing neutral Usage contract. Core replaces a Turn's snapshot atomically;
+repeated snapshots, including terminal repeats, do not add consumption. The
+Session total sums recorded root-Turn measurements, not mixed root/child lists.
+It is best-effort accounting, not an invoice or an estimate of missing work.
+
+- Codex publishes observed snapshots while the Turn is active. Exact native
+ resume excludes the previous Session total from the new Turn. A complete public
+ breakdown requires valid input, cached, output, reasoning and total counters.
+- Claude retains native result evidence internally. Its per-turn and cumulative
+ query/model counters have different scopes, and reasoning attribution can be
+ incomplete. Complete public TokenUsage remains unqualified and null.
+- MiniMax ACP context occupancy describes context capacity, not measured resource
+ consumption. Complete public TokenUsage remains unqualified and null.
+
+Persisted snapshots survive cancellation and Core worker restart. This does not
+recover observations lost before journal commit, stop external tool side effects,
+or replay interrupted input. Provider/model reroute attribution, billed costs,
+unknown historical counters and unreported partial usage remain outside this
+milestone. Native differences must not be hidden with guessed zero counters.
+
+## Official-service observation, 2026-09-22
+
+A bounded probe used the fixed Python SDK 3.13.0, raw HTTP, `agents=v1`, and one
+owned `environment:none` Session with two tiny real-model text Turns. It did not
+read unrelated resources or use tools, workspace execution or Subagents. The
+owned Session was deleted after the probe.
+
+- Creation returned HTTP 201 SSE. Input submission returned HTTP 202.
+- First-turn events followed queued/in-progress, Item/text output and terminal
+ transitions. Its terminal usage was null. Later Session and Turn reads reported
+ input 7226, cached 0, output 7, reasoning 0, total 7233.
+- Reconnection with an old `Last-Event-ID` produced no historical frames during
+ three idle seconds, then delivered new second-Turn frames.
+- After closing the second observer stream, queries recovered both completed
+ Turns and all four Items. Ascending and descending limit-one pages reversed
+ exactly, with exclusive sampled cursors. The Turn completed before the client
+ disconnected, so this alone does not prove survival of an active disconnection.
+- Session usage after the second Turn was null, as was that Turn's usage in the
+ observed pages. No later aggregation behavior was inferred. Twenty-two captured
+ events and sampled resources passed strict pinned-type validation.
+
+These are bounded observations, not complete timing or accounting guarantees.
+Same-timestamp paging, failures/cancellation and Subagent accounting were not
+probed against the official service. The current documentation describes an
+Items `turn_id` filter absent from the pinned SDK and requires initial input for
+`none` where the pin describes it as optional. Neither difference changes the
+repository baseline; the probe supplied initial input and sent no unpinned filter.
+
+Sources: [Sessions](https://developers.openai.com/api/docs/guides/agents-api/sessions),
+[Events](https://developers.openai.com/api/docs/guides/agents-api/sessions/events),
+[Observability](https://developers.openai.com/api/docs/guides/agents-api/observability).
+Sanitized request evidence is retained privately under
+`~/.parsar/remediation/20260922/history-events-usage/official/`; credentials are
+excluded from source and evidence.
From 5e0dbfb35d9a75b6653ed0b72516b115855e390e Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:44:46 +0800
Subject: [PATCH 5/9] test(web): align patch fixtures with function result
protocol
---
apps/web/e2e/agents-lifecycle.spec.ts | 5 ++++-
apps/web/e2e/fixture-core.mjs | 5 +++--
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts
index 640a954ef..d179f0e0f 100644
--- a/apps/web/e2e/agents-lifecycle.spec.ts
+++ b/apps/web/e2e/agents-lifecycle.spec.ts
@@ -2945,7 +2945,8 @@ test("presents an honest searchable Trace workbench without changing the convers
await search.fill("");
const patchRow = trace.locator(".trace-ledger-row-tools").filter({ hasText: "apply_patch" }).first();
- await expect(patchRow.locator('.trace-row-duration [aria-hidden="true"]')).toHaveText("41 ms");
+ await expect(patchRow.locator('.trace-row-duration [aria-hidden="true"]')).toHaveText("—");
+ await expect(patchRow.locator(".trace-row-duration")).toHaveAttribute("title", "Core does not provide per-item timing.");
await patchRow.click();
const detail = page.getByRole("complementary", { name: "Trace item details" });
await expect(detail).toBeVisible();
@@ -3072,6 +3073,8 @@ test("renders Parsar patches as accessible read-only diffs in desktop and narrow
const failedStep = failedTrace.locator('[data-trace-step="patch_failed"]');
await failedStep.getByRole("button", { name: /apply_patch/ }).click();
await expect(failedStep.locator('[data-patch-status="failed"]')).toContainText("Failed");
+ await failedStep.getByText("Raw result").click();
+ await expect(failedStep).toContainText("fixture failure");
const fallback = page.locator('[data-trace-step="patch_alternate"]');
await fallback.getByRole("button", { name: /apply_patch/ }).click();
await expect(fallback).toContainText("malformed alternate shape");
diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs
index 5aebe44dd..b950ef6b6 100644
--- a/apps/web/e2e/fixture-core.mjs
+++ b/apps/web/e2e/fixture-core.mjs
@@ -20,9 +20,10 @@ function patchItems() {
{ path: "src/delete.ts", kind: { type: "delete", move_path: null }, diff: "@@ -1 +0,0 @@\n-removed" },
] },
},
- { id: "patch_output", turn_id: "turn_patch_completed", type: "function_call_output", status: "completed", call_id: "call_completed", output: { applied: true }, duration_ms: 41 },
+ { id: "patch_output", turn_id: "turn_patch_completed", type: "function_call_output", status: "completed", call_id: "call_completed", output: '{"applied": true}' },
{ id: "patch_running", turn_id: "turn_patch_running", type: "function_call", status: "in_progress", name: "apply_patch", call_id: "call_running", arguments: { changes: [{ path: "src/running.ts", kind: { type: "update" }, diff: "@@ -1 +1 @@\n-wait\n+working" }] } },
- { id: "patch_failed", turn_id: "turn_patch_failed", type: "function_call", status: "failed", name: "apply_patch", call_id: "call_failed", arguments: { changes: [{ path: "src/failed.ts", kind: { type: "delete" }, diff: "@@ -1 +0,0 @@\n-failed" }] }, error: { message: "fixture failure" } },
+ { id: "patch_failed", turn_id: "turn_patch_failed", type: "function_call", status: "failed", name: "apply_patch", call_id: "call_failed", arguments: { changes: [{ path: "src/failed.ts", kind: { type: "delete" }, diff: "@@ -1 +0,0 @@\n-failed" }] } },
+ { id: "patch_failed_output", turn_id: "turn_patch_failed", type: "function_call_output", status: "failed", call_id: "call_failed", error: "fixture failure" },
{ id: "patch_alternate", turn_id: "turn_patch_alternate", type: "function_call", status: "in_progress", name: "apply_patch", call_id: "call_alternate", arguments: { patch: "*** Begin Patch\nmalformed alternate shape" } },
];
}
From 906279095351fbe3b58087035ad1968d62f83b1c Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:46:49 +0800
Subject: [PATCH 6/9] style: remove extra projection file trailing lines
---
packages/agents-client/src/response-projection.ts | 1 -
packages/agents-client/src/usage-projection.ts | 1 -
2 files changed, 2 deletions(-)
diff --git a/packages/agents-client/src/response-projection.ts b/packages/agents-client/src/response-projection.ts
index 8387ed978..35bd50a32 100644
--- a/packages/agents-client/src/response-projection.ts
+++ b/packages/agents-client/src/response-projection.ts
@@ -34,4 +34,3 @@ export function sameResourceId(actual: string, expected: string): boolean {
const canonicalExpected = canonicalUuid(expected);
return canonicalActual !== null && canonicalExpected !== null && canonicalActual === canonicalExpected;
}
-
diff --git a/packages/agents-client/src/usage-projection.ts b/packages/agents-client/src/usage-projection.ts
index a672a1463..e41985922 100644
--- a/packages/agents-client/src/usage-projection.ts
+++ b/packages/agents-client/src/usage-projection.ts
@@ -29,4 +29,3 @@ export function projectTokenUsage(value: unknown, invalid: () => never): AgentSe
output_tokens_details: { reasoning_tokens: outputDetails.reasoning_tokens },
};
}
-
From 3c1f379df4eb7d8245b0e0392c66a07db988269a Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:49:42 +0800
Subject: [PATCH 7/9] docs: preserve native child history publication limits
---
contracts/agents-api/history-events-usage.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md
index ba29e62cd..71e5bd269 100644
--- a/contracts/agents-api/history-events-usage.md
+++ b/contracts/agents-api/history-events-usage.md
@@ -19,6 +19,9 @@ and coordination Items. `agent_message` has no status; reasoning status can be
absent or null. Child discovery can publish an already terminal native Turn in a
`turn.created` snapshot; clients must not reinterpret it as newly queued work.
This does not widen the server's Item or interim reasoning-event coverage.
+Claude and MiniMax retain their settlement-based child-history boundary; accepting
+coordination events does not guarantee continuous child progress or a child Turn
+event on every execution. Recover child state through the Subagent queries.
Use response cursors to page history in the requested direction. Session Turn
lists can include root and child Turns. Root Items and child Items have separate
From 47206e21471296b8a3258a0bb5a46ca437ac52d3 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:53:48 +0800
Subject: [PATCH 8/9] docs: record three-harness history and usage acceptance
---
contracts/agents-api/history-events-usage.md | 30 ++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md
index 71e5bd269..c51459229 100644
--- a/contracts/agents-api/history-events-usage.md
+++ b/contracts/agents-api/history-events-usage.md
@@ -51,6 +51,36 @@ or replay interrupted input. Provider/model reroute attribution, billed costs,
unknown historical counters and unreported partial usage remain outside this
milestone. Native differences must not be hidden with guessed zero counters.
+## Core acceptance, 2026-09-22
+
+Three independent PostgreSQL/Core deployments used Docker V1 Runtime images with
+the integrated daemon, fixed official SDK 3.13.0, raw HTTP and the final TypeScript
+client. Codex and Claude called Kimi K3; MiniMax Code called MiniMax M2.7. Each
+performed real native child delegation and an ordinary root Turn. All three passed
+history identity/content agreement, both paging directions at limits one and two,
+SSE disconnect/reconnect, wrong-tenant denial and stable history after idle Core
+restart without another Turn. Captured coordination events passed the TS parser.
+Claude's final run did not emit a child Turn SSE snapshot; its child query recovery
+passed. This preserves the native publication boundary above.
+
+Codex additionally exposed measured usage while a tool-separated Turn was still
+in progress, then retained it after cancellation, repeated reads and Core restart.
+Controlled adapter tests cover ordered publication and cancellation under output
+backpressure. PostgreSQL tests cover cumulative replacement, duplicate snapshots
+and known usage retained across worker reconciliation after process loss. The
+idle real restart does not substitute for active native crash qualification.
+
+Validation included the complete standalone gate split between server
+`make -o check-web check` and local `make check-web`, Codex adapter race tests,
+286 client tests, 573 Core Web tests and 74 fixture Playwright tests. SQL generation
+matched byte-for-byte. No handler annotation, schema, DB query or migration changed.
+Real proof and first-failure records are retained privately under
+`~/.parsar/remediation/20260922/history-events-usage/live/`. The first Claude probe
+incorrectly required a live child Turn event; the bounded follow-up checked the
+documented coordination stream and child queries. Invalid historical patch fixtures
+were corrected to separate function calls from their result Items, without widening
+production validation. E2B and unrelated product flows were not rerun.
+
## Official-service observation, 2026-09-22
A bounded probe used the fixed Python SDK 3.13.0, raw HTTP, `agents=v1`, and one
From 9dd45c2d1aa9e12bbaa09a496db566f11d44f427 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Tue, 22 Sep 2026 21:55:05 +0800
Subject: [PATCH 9/9] docs: clarify observed Claude child event timing
---
contracts/agents-api/history-events-usage.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/contracts/agents-api/history-events-usage.md b/contracts/agents-api/history-events-usage.md
index c51459229..9e1a42451 100644
--- a/contracts/agents-api/history-events-usage.md
+++ b/contracts/agents-api/history-events-usage.md
@@ -60,8 +60,9 @@ performed real native child delegation and an ordinary root Turn. All three pass
history identity/content agreement, both paging directions at limits one and two,
SSE disconnect/reconnect, wrong-tenant denial and stable history after idle Core
restart without another Turn. Captured coordination events passed the TS parser.
-Claude's final run did not emit a child Turn SSE snapshot; its child query recovery
-passed. This preserves the native publication boundary above.
+Claude's first run had coordination events but no child Turn SSE snapshot; the
+final run also captured and parsed child Turn snapshots. Both observations retain
+the native publication boundary above, without a continuous-progress guarantee.
Codex additionally exposed measured usage while a tool-separated Turn was still
in progress, then retained it after cancellation, repeated reads and Core restart.