Skip to content

feat(agent): stamp an absolute emittedAt on persisted run events - #3519

Merged
kojiwakayama merged 2 commits into
mainfrom
feat/run-event-emitted-at
Aug 9, 2026
Merged

kojiwakayama merged 2 commits into
mainfrom
feat/run-event-emitted-at

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #3509, which finally landed elapsedMs in production. This adds the primitive that would have prevented most of the detour getting there.

Why an absolute timestamp

elapsedMs is measured from the encoder's construction, so reading it correctly requires knowing which encoder instance produced it and when. That anchor is not incidental — it is the defect behind three of the four attempts it took to land the field at all:

Both were correct fixes to encoders production does not use. emittedAt has no anchor: it means the same thing regardless of which encoder, run, pod or service produced it.

What that buys:

  • durations between any two events, not only from run start
  • alignment with Tempo traces and Loki logs, which are already wall-clock
  • ingest lag as created_at − emittedAt — turning the gap that produced the phantom "metronomic 30ms delta cadence" in the original trace analysis into a measurable quantity rather than a trap

Two deliberate choices

Epoch milliseconds, not an ISO string. Comparable without parsing, and roughly a third of the bytes on a stream that carries thousands of events per run.

Both fields kept, because they fail differently. A wall clock can step backwards under NTP correction and yield a negative duration; performance.now() cannot. So emittedAt is the durable record and elapsedMs stays the trustworthy intra-run measure. They are complementary, not redundant.

Both edits, in one change

A run-event field needs two edits, and omitting the second is silent — formatAgUiEvent serializes the schema parse result, so an undeclared field is dropped after being correctly stamped. That is exactly what cost four releases for elapsedMs.

  1. stamped in browser-encoder (stampElapsed → stampTiming)
  2. declared in the ag-ui-sse allow-list (withElapsed → withTiming, applied to all 18 event schemas)

Verification

End-to-end through the real production composition root:

data: {"stepName":"step-1","elapsedMs":2,"emittedAt":1786305440611}

Tests, all verified to fail without the change:

  • emittedAt is the wall clock in epoch ms, and elapsedMs stays anchored to construction — pinning that the two are independent
  • opting out of the elapsed clock still yields emittedAt — losing one clock must not silently lose the other, which is this field's whole failure history
  • emittedAt reaches the wire; removing the allow-list declaration fails with the identical historical symptom: {"stepName":"step-1"}
  • the allow-list stays closed to undeclared fields

1,214 tests pass across src/agent, src/internal-agents, src/chat, src/runtime. Typecheck, lint, fmt clean; API reference regenerated.

Note

The clock options are threaded through four wrapper layers (runtime-event-encoder, chunk-encoder-bridge, browser-chunk-encoder, chat-ui-chunk-browser-encoder). That repetition is a mild smell — a single timing options object passed wholesale would be tidier — but it is left as-is here to keep this change reviewable alongside the shipped elapsedMs work.

Summary by CodeRabbit

  • New Features

    • AG-UI events now support wall-clock emittedAt timestamps alongside elapsed-time tracking.
    • Timing options are grouped into a single configuration for browser, chat, runtime, and bridge encoders.
    • Event formatting preserves both supported timing fields while filtering unexpected fields.
  • Bug Fixes

    • Ensured timestamps remain available when elapsed-time tracking is disabled.
  • Documentation

    • Updated API reference line references without changing documented names or descriptions.

`elapsedMs` is measured from the encoder's construction, so reading it
correctly requires knowing which encoder instance produced it and when.
That anchor is not incidental -- it is the defect behind three of the four
attempts it took to land `elapsedMs` at all. #3483 clocked
`run-chunk-mirror`, #3497 clocked `hosted-lifecycle`, and each was a
correct fix to an encoder production does not use.

`emittedAt` has no anchor. It means the same thing regardless of which
encoder, run, pod or service produced it, which is what makes it the
durable primitive:

- durations between any two events, not only from run start
- lines up with Tempo traces and Loki logs, which are already wall clock
- ingest lag becomes `created_at - emittedAt`, turning the gap that
  produced the phantom 30ms delta cadence in the original trace analysis
  into a measurable quantity rather than a trap

Stored as epoch milliseconds rather than an ISO string: comparable without
parsing, and roughly a third of the bytes on a stream that can carry
thousands of events per run.

Both fields are kept, because they fail differently. A wall clock can step
backwards under NTP correction and yield a negative duration; the
monotonic reading cannot. So `emittedAt` is the durable record and
`elapsedMs` remains the trustworthy intra-run measure.

Declared in the `ag-ui-sse` allow-list in the same change. That is the
second edit a run-event field needs, and omitting it is silent: the schema
`parse` result is what reaches the wire, so an undeclared field is dropped
after being stamped. A test asserts `emittedAt` survives to the wire and
fails with exactly that symptom without the declaration.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

AG-UI encoders now use grouped timing options. They can add independent elapsed and epoch timestamps. SSE schemas preserve both fields, and API reference links were refreshed.

Changes

AG-UI timing

Layer / File(s) Summary
Browser timing and emitted timestamps
src/agent/ag-ui/browser-encoder.ts, src/agent/ag-ui/browser-encoder.test.ts
The browser encoder supports independent nowMs and epochMs clocks. Mapping and finalization can add elapsedMs and emittedAt.
Timing option propagation
src/agent/ag-ui/*chunk-encoder.ts, src/agent/ag-ui/chunk-encoder-bridge.ts, src/agent/ag-ui/runtime-event-encoder.ts, src/agent/ag-ui/*encoder.test.ts, docs/api-reference/veryfront/agent.md
Encoder options replace top-level nowMs with nested timing configuration. Tests and source references use the updated API shape.
SSE timing schema and serialization
src/internal-agents/ag-ui-sse.ts, src/internal-agents/ag-ui-sse.test.ts
AG-UI SSE schemas validate and preserve both elapsedMs and emittedAt. Tests cover timestamp serialization and disabled epoch clocks.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RuntimeEventEncoder
  participant AgUiBrowserEncoderState
  participant BrowserEventMapper
  participant AgUiSSESchema
  RuntimeEventEncoder->>AgUiBrowserEncoderState: pass timing configuration
  AgUiBrowserEncoderState->>BrowserEventMapper: provide elapsed and epoch clocks
  BrowserEventMapper->>AgUiSSESchema: emit event with elapsedMs and emittedAt
  AgUiSSESchema-->>RuntimeEventEncoder: validate and serialize AG-UI payload
Loading

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding absolute emittedAt timestamps to persisted run events.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/run-event-emitted-at

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7fdca927da

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +706 to +707
if (state.epochMs) {
timing.emittedAt = Math.round(state.epochMs());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stamp directly emitted lifecycle frames

When any AG-UI response starts, RunStarted, StateSnapshot, and MessagesSnapshot are written directly through formatAgUiEvent in handler.ts, runtime-handler.ts, and browser-response-stream.ts; catch-path RunError frames follow the same path. Because only events passed through stampTiming receive emittedAt, these frames omit the new timestamp on every run, preventing ingest-lag or cross-event timing analysis for those persisted rows. Route these direct frames through the timing stamper or add the timestamp before formatting.

Useful? React with 👍 / 👎.

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 9, 2026
The four wrappers between a caller and the encoder state each declared and
re-forwarded `nowMs` and `epochMs` individually. Adding `emittedAt` meant
touching all four, purely to pass a value through.

That repetition is the same shape as the bug this field spent four
releases on: a value that has to be re-declared at every layer is a value
that only has to be missed once. Forwarding a single `timing` object means
adding a clock is one edit in `AgUiBrowserEncoderStateOptions`, and the
wrappers do not change at all.

  runtime-event-encoder, chunk-encoder-bridge -> createAgUiBrowserEncoderState(options.timing ?? {})
  browser-chunk-encoder, chat-ui-chunk-browser-encoder -> pass `timing` straight through

No behaviour change: 1212 tests pass, and the timing tests still assert
`emittedAt` and `elapsedMs` reach the wire independently.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 9, 2026
@kojiwakayama
kojiwakayama enabled auto-merge August 9, 2026 21:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/agent/ag-ui/runtime-event-encoder.ts`:
- Around line 23-35: Retain the deprecated top-level nowMs option across
src/agent/ag-ui/runtime-event-encoder.ts:23-35,
src/agent/ag-ui/browser-chunk-encoder.ts:23-28,
src/agent/ag-ui/chat-ui-chunk-browser-encoder.ts:24-29, and
src/agent/ag-ui/chunk-encoder-bridge.ts:22-34 so existing TypeScript and
JavaScript callers remain compatible. Forward or normalize nowMs into timing,
define and consistently apply precedence when both are provided, and preserve
explicit values such as { nowMs: null }; add regression tests covering that
case.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: bf84a063-32ab-4d46-a76b-da621eb825dd

📥 Commits

Reviewing files that changed from the base of the PR and between ef37258 and efea2d2.

📒 Files selected for processing (12)
  • docs/api-reference/veryfront/agent.md
  • src/agent/ag-ui/browser-chunk-encoder.test.ts
  • src/agent/ag-ui/browser-chunk-encoder.ts
  • src/agent/ag-ui/browser-encoder.test.ts
  • src/agent/ag-ui/browser-encoder.ts
  • src/agent/ag-ui/chat-ui-chunk-browser-encoder.test.ts
  • src/agent/ag-ui/chat-ui-chunk-browser-encoder.ts
  • src/agent/ag-ui/chunk-encoder-bridge.ts
  • src/agent/ag-ui/runtime-event-encoder.test.ts
  • src/agent/ag-ui/runtime-event-encoder.ts
  • src/internal-agents/ag-ui-sse.test.ts
  • src/internal-agents/ag-ui-sse.ts

Comment on lines +23 to +35
/**
* Timing clocks forwarded verbatim to the encoder state. A single object so
* that adding a clock is one edit in `AgUiBrowserEncoderStateOptions`, not a
* sweep through every wrapper that happens to sit in between.
*/
timing?: AgUiBrowserEncoderStateOptions;
}

/** Create AG-UI runtime event encoder. */
export function createAgUiRuntimeEventEncoder(
options: CreateAgUiRuntimeEventEncoderOptions = {},
): AgUiRuntimeEventEncoder {
const state = createAgUiBrowserEncoderState(
options.nowMs === undefined ? {} : { nowMs: options.nowMs },
);
const state = createAgUiBrowserEncoderState(options.timing ?? {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the top-level nowMs option.

These exported option types remove nowMs. Existing TypeScript callers now fail type checking. Existing JavaScript callers silently use default clocks.

Keep nowMs as a deprecated alias. Normalize it into timing. Define precedence when callers provide both options. Add regression tests that use { nowMs: null }.

  • src/agent/ag-ui/runtime-event-encoder.ts#L23-L35: retain and normalize the legacy option.
  • src/agent/ag-ui/browser-chunk-encoder.ts#L23-L28: retain and forward the legacy option.
  • src/agent/ag-ui/chat-ui-chunk-browser-encoder.ts#L24-L29: retain and forward the legacy option.
  • src/agent/ag-ui/chunk-encoder-bridge.ts#L22-L34: retain and normalize the legacy option.

As per coding guidelines, “Prefer the smallest viable diff; preserve public API compatibility.”

📍 Affects 4 files
  • src/agent/ag-ui/runtime-event-encoder.ts#L23-L35 (this comment)
  • src/agent/ag-ui/browser-chunk-encoder.ts#L23-L28
  • src/agent/ag-ui/chat-ui-chunk-browser-encoder.ts#L24-L29
  • src/agent/ag-ui/chunk-encoder-bridge.ts#L22-L34
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/ag-ui/runtime-event-encoder.ts` around lines 23 - 35, Retain the
deprecated top-level nowMs option across
src/agent/ag-ui/runtime-event-encoder.ts:23-35,
src/agent/ag-ui/browser-chunk-encoder.ts:23-28,
src/agent/ag-ui/chat-ui-chunk-browser-encoder.ts:24-29, and
src/agent/ag-ui/chunk-encoder-bridge.ts:22-34 so existing TypeScript and
JavaScript callers remain compatible. Forward or normalize nowMs into timing,
define and consistently apply precedence when both are provided, and preserve
explicit values such as { nowMs: null }; add regression tests covering that
case.

Source: Coding guidelines

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 9, 2026
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 4f6f158 Aug 9, 2026
31 checks passed
@kojiwakayama
kojiwakayama deleted the feat/run-event-emitted-at branch August 9, 2026 21:31
This was referenced Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant