feat(react-pi): map settled and appended events#5030
feat(react-pi): map settled and appended events#5030Kinfe123 wants to merge 5 commits intomainassistant-ui/assistant-ui:mainfrom feat/react-pi-session-eventsassistant-ui/assistant-ui:feat/react-pi-session-eventsCopy head branch name to clipboard
Conversation
🦋 Changeset detectedLatest commit: 80fdb39 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Claude finished @Kinfe123's task in 3m 54s —— View job Review — PR #5030 (commit 80fdb39)CI is green: Changeset Semver Check, Code Quality (both jobs), autofix.ci, and the PR build all pass. Changes since last reviewThe four fix commits since 3c0ef4c address every issue raised in the previous round:
No new bugs introduced by the fixes. The refresh path coalesces via the existing Critical issuesNone found. SuggestionsTwo minor items posted inline:
One note on the reducer: the explicit Verification noteI could not re-run the package tests in this environment (dependency install requires approval), but the PR build CI job passed on 80fdb39, which covers the 151-test suite the author cites. Good practices
SimplifyThe simplification pass found no critical issues; four minor cleanups, two of which are the inline comments above. The remaining two:
Dismissed after checking: the |
|
Reviews (1): Last reviewed commit: "feat(react-pi): map settled and appended..." | Re-trigger Greptile |
| case "agent_settled": | ||
| case "entry_appended": | ||
| return stamped(state); |
There was a problem hiding this comment.
Appended Messages Are Discarded
PiSessionEntry permits a message entry, but this branch only advances lastSeq and the controller now skips its unknown-event snapshot refresh. If Pi emits an appended message without a matching message_start/message_end stream, the transcript omits that message until a later snapshot or reconnect.
| type PiSessionEntryBase = { | ||
| type: string; | ||
| id: string; | ||
| parentId: string | null; | ||
| timestamp: string; | ||
| }; | ||
|
|
||
| export type PiSessionEntry = | ||
| | (PiSessionEntryBase & { | ||
| type: "message"; | ||
| message: PiAgentMessage; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "thinking_level_change"; | ||
| thinkingLevel: string; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "model_change"; | ||
| provider: string; | ||
| modelId: string; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "compaction"; | ||
| summary: string; | ||
| firstKeptEntryId: string; | ||
| tokensBefore: number; | ||
| details?: unknown; | ||
| fromHook?: boolean; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "branch_summary"; | ||
| fromId: string; | ||
| summary: string; | ||
| details?: unknown; | ||
| fromHook?: boolean; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "custom"; | ||
| customType: string; | ||
| data?: unknown; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "custom_message"; | ||
| customType: string; | ||
| content: string | (PiTextContent | PiImageContent)[]; | ||
| details?: unknown; | ||
| display: boolean; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "label"; | ||
| targetId: string; | ||
| label: string | undefined; | ||
| }) | ||
| | (PiSessionEntryBase & { | ||
| type: "session_info"; | ||
| name?: string; | ||
| }); |
There was a problem hiding this comment.
PiSessionEntry is a closed union in a file whose convention is open unions with an explicit unknown fallback — and it ships on the append-only public surface, so this shape is hard to walk back later.
The neighboring mirrors handle exactly this: PiUnknownAgentMessage (types.ts:165) exists because "Pi augments it via module augmentation", and PiUnknownClientEventBody exists because "unknown event types must be stored, not thrown". Meanwhile the value reaching this type comes from a blind double-cast (toPiSessionEntry in node/mapping.ts:66), so when Pi ships a new entry type it will flow through at runtime typed as a union it doesn't match, and any consumer doing an exhaustive switch on entry.type will silently mis-narrow.
Two options:
- Add a
PiUnknownSessionEntryfallback variant (mirroring thePiAgentMessagepattern). Thentype: stringmoves offPiSessionEntryBaseand onto the fallback — as written the base'stype: stringis dead weight, since every variant intersects it with a literal. - Simplify harder: nothing in the repo reads any variant of the entry (the reducer discards it, the controller only whitelists the type string). The payload could be typed as just the open base shape (
PiSessionEntryBase & { [key: string]: unknown }), deleting ~50 lines of speculative mirror plus the cast helper, and the detailed union can be added once something actually consumes it.
Minor, same block: label: string | undefined (line 230) breaks the JSON-safe-mirror contract under exactOptionalPropertyTypes — after a JSON round-trip over the wire, label: undefined loses its key, so the received object no longer satisfies the type. The session_info_changed mapper strips undefined name for exactly this reason; here the entry passes through by cast, so the honest type is label?: string | undefined.
| case "agent_settled": | ||
| case "entry_appended": | ||
| return stamped(state); |
There was a problem hiding this comment.
Blanket absorption trades away the accidental reconciliation current main gets on Pi 0.80.4+, and not every entry type has a companion event to cover for it.
entry_appended fires once per persisted entry. For message, thinking_level_change, and session_info entries the reducer already learns the same fact from message_*, thinking_level_changed, and session_info_changed — absorbing those is pure win, and it is the fix for the per-entry getThread() storm during runs. But model_change has no companion event in this mapper: a model switch initiated by another client on the same supervisor currently reconciles via the unknown-event refresh, and after this change metadata.config stays stale until the next explicit refresh(). Similar reasoning applies to custom_message (display: true) and label entries appended outside a run, if Pi does not also emit message_* events for them.
Issue #5024 asserts "nothing is silently dropped today" — that claim describes the refresh fallback this PR removes, so it doesn't carry over automatically. If Pi emits companion events for every UI-relevant entry type, this is fine as-is and a short comment here documenting that invariant would close the question. Otherwise, consider absorbing only the entry types known to be duplicated by companion events and letting the rest keep the refresh fallback, e.g. dispatch on event.entry.type here (returning stamped(state) for the covered types) and drop entry_appended from KNOWN_EVENT_TYPES so uncovered entries still trigger refreshInBackground().
| const needsSnapshotRefresh = | ||
| !KNOWN_EVENT_TYPES.has(event.type) || | ||
| (event.type === "entry_appended" && event.entry.type !== "custom"); |
There was a problem hiding this comment.
event.entry.type can throw under server/client version skew. Events arrive as JSON.parse(frame.data) as PiAnyClientEvent with no runtime validation (eventSource.ts:198), and the previous @assistant-ui/react-pi/node mapper strips everything but type from unknown events — so an older server forwards entry_appended as { type: "entry_appended" } with no entry. On a client running this code, that's a TypeError here, which propagates out of onEvent into the event-stream read loop's catch and forces a full reconnect + snapshot per occurrence — worse churn than the background refresh this PR removes, and repeated for every appended entry.
| const needsSnapshotRefresh = | |
| !KNOWN_EVENT_TYPES.has(event.type) || | |
| (event.type === "entry_appended" && event.entry.type !== "custom"); | |
| const needsSnapshotRefresh = | |
| !KNOWN_EVENT_TYPES.has(event.type) || | |
| (event.type === "entry_appended" && event.entry?.type !== "custom"); |
The optional chain also does the right thing semantically: a payload with no entry is exactly the case where a snapshot refresh is needed.
| case "agent_settled": | ||
| case "entry_appended": | ||
| return stamped(state); |
There was a problem hiding this comment.
Minor: these two cases return exactly what the default branch below returns, and the package's existing convention for known-but-absorbed events (turn_start, turn_end) is to let them fall through to the default — the absorb-vs-refresh decision lives entirely in the controller's KNOWN_EVENT_TYPES. Deleting the explicit cases is zero behavior change and keeps one convention. Fine to keep if you prefer the self-documenting cases, but then turn_start/turn_end arguably deserve the same treatment.
There was a problem hiding this comment.
1 issue found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/react-pi/src/node/mapping.ts">
<violation number="1" location="packages/react-pi/src/node/mapping.ts:147">
P2: Blanket absorption of `entry_appended` means entry types without companion events (e.g., `model_change`, `custom_message` with `display: true`, `label`) will no longer trigger a snapshot refresh, leaving thread state stale until the next explicit `refresh()` or reconnect. Consider dispatching on `event.entry.type` here — returning `stamped(state)` only for entry types known to be duplicated by companion events (e.g., `message`, `thinking_level_change`, `session_info`) and falling through to the default for uncovered types so they still benefit from the controller's background refresh.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| aborted: event.aborted, | ||
| willRetry: event.willRetry, | ||
| }; | ||
| case "entry_appended": |
There was a problem hiding this comment.
P2: Blanket absorption of entry_appended means entry types without companion events (e.g., model_change, custom_message with display: true, label) will no longer trigger a snapshot refresh, leaving thread state stale until the next explicit refresh() or reconnect. Consider dispatching on event.entry.type here — returning stamped(state) only for entry types known to be duplicated by companion events (e.g., message, thinking_level_change, session_info) and falling through to the default for uncovered types so they still benefit from the controller's background refresh.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/react-pi/src/node/mapping.ts, line 147:
<comment>Blanket absorption of `entry_appended` means entry types without companion events (e.g., `model_change`, `custom_message` with `display: true`, `label`) will no longer trigger a snapshot refresh, leaving thread state stale until the next explicit `refresh()` or reconnect. Consider dispatching on `event.entry.type` here — returning `stamped(state)` only for entry types known to be duplicated by companion events (e.g., `message`, `thinking_level_change`, `session_info`) and falling through to the default for uncovered types so they still benefit from the controller's background refresh.</comment>
<file context>
@@ -138,6 +144,11 @@ export const mapSessionEvent = (
aborted: event.aborted,
willRetry: event.willRetry,
};
+ case "entry_appended":
+ return {
+ type: "entry_appended",
</file context>
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| const toPiSessionEntry = (entry: SessionEntry): PiSessionEntry => | ||
| entry as unknown as PiSessionEntry; |
There was a problem hiding this comment.
Minor: the cast target is the closed PiSessionEntry union, but the event field this feeds (entry_appended.entry) is typed PiAnySessionEntry — and the whole point of the PiUnknownSessionEntry fallback is that a future Pi SDK may emit entry variants the mirror doesn't name. Casting to the closed union types a future unknown variant as a known one inside this module, undercutting the forward-compat design the rest of the PR added.
Since this is single-use, you could also drop the helper and inline the cast, matching how the same switch already handles assistantMessageEvent (line 108):
| const toPiSessionEntry = (entry: SessionEntry): PiSessionEntry => | |
| entry as unknown as PiSessionEntry; | |
| const toPiSessionEntry = (entry: SessionEntry): PiAnySessionEntry => | |
| entry as unknown as PiAnySessionEntry; |
| const KNOWN_PI_SESSION_ENTRY_TYPES = { | ||
| message: true, | ||
| thinking_level_change: true, | ||
| model_change: true, | ||
| compaction: true, | ||
| branch_summary: true, | ||
| custom: true, | ||
| custom_message: true, | ||
| label: true, | ||
| session_info: true, | ||
| } satisfies Record<PiSessionEntry["type"], true>; | ||
|
|
||
| export const isKnownPiSessionEntry = ( | ||
| entry: PiAnySessionEntry, | ||
| ): entry is PiSessionEntry => | ||
| Object.prototype.hasOwnProperty.call( | ||
| KNOWN_PI_SESSION_ENTRY_TYPES, | ||
| entry.type, | ||
| ); |
There was a problem hiding this comment.
Two considerations on the guard:
-
It ships on the append-only surface with no in-repo consumer. Nothing in the package calls
isKnownPiSessionEntry— even the one runtime discrimination site (ThreadController.ts:597) checksentry?.type !== "custom"directly, and the sibling forward-compat unions (PiAgentMessage,PiAnyClientEvent) ship without anisKnown*guard. Per AGENTS.md the public surface is append-only, so exporting this commits it permanently. If the intent is a deliberate consumer-facing API for narrowingentry_appendedpayloads (which is a reasonable answer to the earlier narrowing feedback), a sentence in the PR body stating that would settle it; otherwise consider deferring the export (guard + table +types.test.ts) until something consumes it. -
Nit: the repo convention is
Object.hasOwn(...)(used inpackages/core,packages/ui,packages/react-generative-ui) rather thanObject.prototype.hasOwnProperty.call(...)— same prototype-pollution safety, shorter:
export const isKnownPiSessionEntry = (
entry: PiAnySessionEntry,
): entry is PiSessionEntry =>
Object.hasOwn(KNOWN_PI_SESSION_ENTRY_TYPES, entry.type);
Summary
agent_settledandentry_appendedevents in the browser-safe event contractagent_settledand the custom entries Pi currently emits, while retaining reconciliation for unsupported future entry variantsWhy
Pi 0.80.x emits
entry_appendedwhen extensions persist custom session entries, but React Pi treated it andagent_settledas unknown events. That dropped the appended entry payload and scheduled unnecessarygetThread()snapshot refreshes.The unknown-event fallback remains unchanged. Non-custom
entry_appendedvariants also retain snapshot reconciliation so a future Pi expansion cannot leave local state stale.Sequencing
This is the behavior follow-up to #5023. The implementation compiles against the Pi 0.80.7 development dependency already on
main, but #5023 should merge first so the supported peer range and provenance update land before this behavior.Verification
pnpm --filter @assistant-ui/react-pi test'(151 tests)'pnpm --filter @assistant-ui/react-pi buildunder Node 24pnpm api-surfacepackage build (40 packages)git diff --checkCloses #5024