diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0676274db..c7966f494 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1651,6 +1651,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/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"}}`))
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" } },
];
}
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/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..9e1a42451
--- /dev/null
+++ b/contracts/agents-api/history-events-usage.md
@@ -0,0 +1,118 @@
+# 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.
+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
+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.
+
+## 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 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.
+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
+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.
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/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..35bd50a32
--- /dev/null
+++ b/packages/agents-client/src/response-projection.ts
@@ -0,0 +1,36 @@
+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..e41985922
--- /dev/null
+++ b/packages/agents-client/src/usage-projection.ts
@@ -0,0 +1,31 @@
+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 },
+ };
+}
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()
}