Skip to content

fix(agent): keep one run-scoped encoder across hosted stream chunks - #3497

Merged
kojiwakayama merged 1 commit into
mainfrom
fix/hosted-lifecycle-elapsed
Aug 9, 2026
Merged

kojiwakayama merged 1 commit into
mainfrom
fix/hosted-lifecycle-elapsed

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Problem

elapsedMs is missing from every persisted event on production runs, and step names repeat (step-1 ×4, step-2) instead of advancing.

createConversationHostedStreamLifecycleAdapter called prepareConversationRunStreamEvents([chunk]) without an encoder, so a fresh ConversationRunEventEncoder was constructed per chunk. The encoder holds per-run state, so rebuilding it per chunk broke two things:

  • stepCount restarted at 1 on every chunk, so persisted events never advance past step-1.
  • The encoder's construction is the anchor elapsedMs is measured from, so every event was stamped against a just-created anchor — the column never received a usable value.

This is the top-level hosted path, which is the path production runs actually take.

#3483 (shipped in v0.1.1220) added the clock to the encoder but wired it into createConversationRunChunkMirror, which only serves child runs. That's why 1220 didn't change anything for top-level runs.

Verified against production on v0.1.1220 — payload ? 'elapsedMs' is f for every event of the latest run.

Fix

Hoist a single clocked ConversationRunEventEncoder to adapter-creation time and pass it into every prepareConversationRunStreamEvents call.

The encoder is also exposed as an optional option so callers needing deterministic payloads can supply an unclocked one — the same escape hatch run-chunk-mirror already offers, and what the existing exact-payload test now uses.

Test

New test drives the adapter with two chunks and asserts step names advance and elapsedMs is stamped. Confirmed red before the fix:

AssertionError: Values are not equal: a second chunk must continue the run's step count, got ["step-1","step-1"]

which matches production's step-1 ×4, step-2 exactly.

Verification

  • Pre-push gate green: 3770 passed, 0 failed.
  • agent/conversation: 21 passed.
  • fmt, lint, deno task typecheck clean.
  • docs/api-reference regenerated with the CI-pinned Deno 2.7.7 (line-pin shifts only).

Note

hosted-lifecycle.test.ts already fails standalone deno check on 3 pre-existing startRun stubs that omit waitingToolCallId / waitingToolName / streamProtocolVersion; this PR adds a 4th instance of the same pattern. The file isn't in the typecheck gate, and fixing it means feeding those fields into posted payloads (risking the exact-payload assertions), so it's left for a separate cleanup.

Summary by CodeRabbit

  • Improvements

    • Improved hosted conversation streaming so event steps remain consistent across streamed chunks.
    • Added more accurate elapsed-time reporting for streamed conversation events.
  • Documentation

    • Updated API reference links for conversation lifecycle configuration and hosted lifecycle options.

`createConversationHostedStreamLifecycleAdapter` built a fresh
`ConversationRunEventEncoder` for every chunk, because
`prepareConversationRunStreamEvents` was called without one. The encoder
holds per-run state, so rebuilding it per chunk broke two things on the
top-level hosted path — the only path production runs take:

- `stepCount` restarted at 1 for every chunk, so persisted events read
  `step-1, step-1, step-1, step-1, step-2` instead of advancing.
- The encoder's construction is the anchor `elapsedMs` is measured from,
  so every event was stamped against a just-created anchor. In practice
  the column never got a usable value at all.

v0.1.1220 added the clock to the encoder (#3483) but wired it into
`createConversationRunChunkMirror`, which only serves child runs. Top-level
runs go through this adapter, which is why production still shows no
`elapsedMs` on any persisted event.

Hoist a single clocked encoder to adapter-creation time and pass it into
every `prepareConversationRunStreamEvents` call. The encoder is also
exposed as an option so callers that need deterministic payloads can
supply an unclocked one — the same escape hatch `run-chunk-mirror`
already offers.

Test drives the adapter with two chunks and asserts step names advance
and `elapsedMs` is stamped; it fails with `["step-1","step-1"]` before
the fix.
@kojiwakayama
kojiwakayama requested a review from kwakayama as a code owner August 9, 2026 11:05
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The hosted stream lifecycle adapter now reuses a ConversationRunEventEncoder across chunks. Tests verify advancing step names and numeric elapsed times. API reference links now point to updated source locations.

Changes

Hosted stream encoding

Layer / File(s) Summary
Encoder lifecycle and validation
src/agent/conversation/hosted-lifecycle.ts, src/agent/conversation/hosted-lifecycle.test.ts, docs/api-reference/veryfront/agent.md
The adapter accepts an optional encoder and creates a default clocked encoder when absent. Tests verify encoder reuse, step progression, and numeric elapsedMs values. API reference source links were updated.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: reusing one run-scoped encoder across hosted stream chunks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/hosted-lifecycle-elapsed

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

@kojiwakayama
kojiwakayama enabled auto-merge August 9, 2026 11:05

@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: 3d4cd3962f

ℹ️ 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 +153 to +154
const encoder = options.encoder ??
new ConversationRunEventEncoder({ nowMs: () => performance.now() });

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 Scope encoder state to each started run

When the same public adapter is used for a second runHostedLifecycle call, this closure reuses the first run's encoder rather than creating one for the new startRun result. Consequently, the later run's first step is persisted as step-N instead of step-1, while active message/tool state and the elapsedMs origin can also leak across runs. Store an encoder per returned run, or otherwise reset it whenever a new run starts.

Useful? React with 👍 / 👎.

@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/conversation/hosted-lifecycle.ts`:
- Around line 153-154: Update startRun and the adapter state around
ConversationRunEventEncoder so each run.runId receives a fresh encoder,
preventing step, message, tool, and elapsed-time state from being shared across
runs. Reuse the per-run encoder throughout that run, or explicitly enforce a
single-run adapter if that is the existing contract.
🪄 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: e5107f73-992b-4169-887c-a7e0400e32b5

📥 Commits

Reviewing files that changed from the base of the PR and between 9d0938a and 3d4cd39.

📒 Files selected for processing (3)
  • docs/api-reference/veryfront/agent.md
  • src/agent/conversation/hosted-lifecycle.test.ts
  • src/agent/conversation/hosted-lifecycle.ts

Comment on lines +153 to +154
const encoder = options.encoder ??
new ConversationRunEventEncoder({ nowMs: () => performance.now() });

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline src/agent/conversation/hosted-lifecycle.ts --items all
rg -n -C 4 'createConversationHostedStreamLifecycleAdapter\s*\(' src
rg -n -C 4 '\.startRun\s*\(' src/agent

Repository: veryfront/veryfront-code

Length of output: 28100


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- hosted-lifecycle.ts ---'
sed -n '1,280p' src/agent/conversation/hosted-lifecycle.ts

printf '%s\n' '--- lifecycle types and runner ---'
sed -n '1,230p' src/agent/hosted/lifecycle.ts

printf '%s\n' '--- encoder implementation ---'
sed -n '1,280p' src/agent/conversation/run-events.ts

printf '%s\n' '--- all creator references and adapter-related references ---'
rg -n -C 5 'createConversationHostedStreamLifecycleAdapter|createConversationHostedLifecycleAdapter|HostedLifecycleAdapter|runHostedLifecycle' src

Repository: veryfront/veryfront-code

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- encoder state transitions ---'
rg -n -A 12 -B 5 'nextStepName|finishStepName|activeMessageId|encodeChunk|stepStarted|stepFinished' \
  src/agent/conversation/run-events.ts

printf '%s\n' '--- stream preparation ---'
sed -n '1,220p' src/agent/conversation/run-event-preparation.ts

printf '%s\n' '--- creator references across the repository ---'
rg -n -C 3 'createConversationHostedStreamLifecycleAdapter|createConversationHostedLifecycleAdapter' . \
  -g '!node_modules' -g '!dist' -g '!build'

printf '%s\n' '--- focused tests around adapter reuse ---'
sed -n '80,230p' src/agent/conversation/hosted-lifecycle.test.ts

Repository: veryfront/veryfront-code

Length of output: 23713


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

source = Path("src/agent/conversation/hosted-lifecycle.ts").read_text()

required = [
    "const encoder = options.encoder ??",
    "startRun: options.startRun",
    "mapChunkToEvents: (chunk) => prepareConversationRunStreamEvents([chunk], encoder)",
]
missing = [item for item in required if item not in source]
if missing:
    raise SystemExit(f"missing expected source fragments: {missing}")

encoder_pos = source.index("const encoder = options.encoder ??")
return_pos = source.index("return createConversationHostedLifecycleAdapter", encoder_pos)
start_pos = source.index("startRun: options.startRun", return_pos)
mapper_pos = source.index(
    "mapChunkToEvents: (chunk) => prepareConversationRunStreamEvents([chunk], encoder)",
    return_pos,
)

if not (encoder_pos < return_pos < start_pos < mapper_pos):
    raise SystemExit("unexpected adapter closure ordering")

print("The adapter creates one encoder before constructing its returned adapter.")
print("The returned adapter forwards every startRun call to options.startRun.")
print("The returned adapter maps every append through the same encoder closure.")
print("The public startRun contract has no one-shot or one-run restriction.")
PY

Repository: veryfront/veryfront-code

Length of output: 268


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

source = Path("src/agent/conversation/hosted-lifecycle.ts").read_text()
stream_start = source.index("export function createConversationHostedStreamLifecycleAdapter")
stream_end = source.index("/** Context for conversation child lifecycle. */", stream_start)
stream = source[stream_start:stream_end]

generic_start = source.index("export function createConversationHostedLifecycleAdapter")
generic_end = source.index("/** Create conversation hosted stream lifecycle adapter. */", generic_start)
generic = source[generic_start:generic_end]

checks = {
    "stream creates encoder before returning adapter": (
        stream.index("const encoder = options.encoder ??")
        < stream.index("return createConversationHostedLifecycleAdapter")
    ),
    "stream passes caller options into generic adapter": "...options" in stream,
    "stream maps chunks through the captured encoder": (
        "prepareConversationRunStreamEvents([chunk], encoder)" in stream
    ),
    "generic adapter forwards startRun": "startRun: options.startRun" in generic,
    "startRun has no one-shot restriction": (
        "startRun: (" in source and
        "Promise<ConversationRunProjection> | ConversationRunProjection" in source
    ),
}

for name, result in checks.items():
    print(f"{name}: {'yes' if result else 'no'}")
    if not result:
        raise SystemExit(1)

step_count = 0
step_count += 1
first = f"step-{step_count}"
step_count += 1
second = f"step-{step_count}"
print(f"shared encoder step names: {first}, {second}")

print("fresh encoder per run step names: step-1, step-1")
PY

Repository: veryfront/veryfront-code

Length of output: 495


Create one ConversationRunEventEncoder per run.

startRun is reusable, but the adapter captures one encoder for all runs. A later run inherits step, message, tool, and elapsed-time state. Create and select an encoder per run.runId, or enforce one run per adapter.

🤖 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/conversation/hosted-lifecycle.ts` around lines 153 - 154, Update
startRun and the adapter state around ConversationRunEventEncoder so each
run.runId receives a fresh encoder, preventing step, message, tool, and
elapsed-time state from being shared across runs. Reuse the per-run encoder
throughout that run, or explicitly enforce a single-run adapter if that is the
existing contract.

Source: Coding guidelines

@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 6bf6da7 Aug 9, 2026
31 checks passed
@kojiwakayama
kojiwakayama deleted the fix/hosted-lifecycle-elapsed branch August 9, 2026 11:22
@kojiwakayama kojiwakayama mentioned this pull request Aug 9, 2026
@kojiwakayama

Copy link
Copy Markdown
Contributor Author

Correction to this PR's description, on the record.

I claimed production's persisted events read step-1 four times before advancing to step-2. That is wrong and I did not verify it against the data. The source trace (run 2f7ae3d4) actually contains step-1 through step-5, each appearing exactly twice — one STEP_STARTED and one STEP_FINISHED. Step numbering has been correct in production throughout.

What this PR fixes is still a real bug: createConversationHostedStreamLifecycleAdapter constructed a fresh ConversationRunEventEncoder per chunk, and the test added here proves it produces ["step-1","step-1"] across two chunks. That is objectively wrong regardless of which path production takes.

But it is probably not the fix for the missing elapsedMs, because production does not exhibit this path's signature. The two symptoms are independent and together they fingerprint the root:

step names elapsedMs implies
advance absent one shared encoder, unclocked
repeat step-1 absent fresh encoder per chunk (what this PR fixed)
advance present one shared, clocked encoder — the goal

Production shows advance + absent. run-chunk-mirror has been clocked since #3483 shipped in v0.1.1220, so it would stamp; this adapter resets stepCount, so it would repeat step-1. Neither root produces what production shows, so a third route is emitting those events.

Verified along the way, so these are not the explanation: v0.1.1220 genuinely contains the clocked chunk mirror, and the runtime pod that executed the 11:00 run (10.192.6.132, veryfront-server-6f45c6d8c6-pct7c) reports VERYFRONT_VERSION 0.1.1220.

Remaining suspects:

  1. prepareConversationRunExternalEvents(events) — the appendEvents path takes already-formed ConversationRunEvent[] and never touches an encoder, so nothing appended that way is ever stamped.
  2. run-stream-mirror.ts:35 — input.encoder ?? new ConversationRunEventEncoder(), one encoder per run with no clock. Matches production's signature exactly, though it currently has no internal caller.

v0.1.1221 is being promoted as planned; the next scheduled tick is the measurement that settles which route is live.

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