Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(react-pi): map settled and appended events#5030

Open
Kinfe123 wants to merge 5 commits into
mainassistant-ui/assistant-ui:mainfrom
feat/react-pi-session-eventsassistant-ui/assistant-ui:feat/react-pi-session-eventsCopy head branch name to clipboard
Open

feat(react-pi): map settled and appended events#5030
Kinfe123 wants to merge 5 commits into
mainassistant-ui/assistant-ui:mainfrom
feat/react-pi-session-eventsassistant-ui/assistant-ui:feat/react-pi-session-eventsCopy head branch name to clipboard

Conversation

@Kinfe123

@Kinfe123 Kinfe123 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • model Pi 0.80.x agent_settled and entry_appended events in the browser-safe event contract
  • preserve the complete persisted session entry through the Node mapper with a forward-compatible fallback type
  • avoid snapshot reconciliation for agent_settled and the custom entries Pi currently emits, while retaining reconciliation for unsupported future entry variants

Why

Pi 0.80.x emits entry_appended when extensions persist custom session entries, but React Pi treated it and agent_settled as unknown events. That dropped the appended entry payload and scheduled unnecessary getThread() snapshot refreshes.

The unknown-event fallback remains unchanged. Non-custom entry_appended variants 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 build under Node 24
  • full pnpm api-surface package build (40 packages)
  • Node 24 filtered API-surface check
  • targeted oxlint and oxfmt checks
  • git diff --check

Closes #5024

Review in cubic

@Kinfe123
Kinfe123 requested a review from a team July 17, 2026 21:55
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 80fdb39

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@assistant-ui/react-pi Patch

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

@rupic-app rupic-app Bot added stage/needs-review No reviews yet; waiting for a maintainer pkg/react-pi Changes a package under packages/ R0 Act now: stale, blocked, or high impact type/feature New feature or capability labels Jul 17, 2026
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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 review

The four fix commits since 3c0ef4c address every issue raised in the previous round:

Previous issue Status
Closed PiSessionEntry union with no forward-compat fallback (claude, types.ts) ✅ Fixed in ad4bd72/80fdb39 — PiUnknownSessionEntry fallback, PiAnySessionEntry, and an exported isKnownPiSessionEntry guard backed by a satisfies Record<PiSessionEntry["type"], true> exhaustiveness table
label: string | undefined breaks the JSON-round-trip contract under exactOptionalPropertyTypes ✅ Fixed — now label?: string | undefined
Blanket entry_appended absorption leaves entries without companion events stale (model_change, custom_message, label) — raised by claude, cubic, and greptile ("appended messages discarded") ✅ Fixed in ea14f4a — only custom entries are absorbed; every other variant still schedules a snapshot refresh, with the Pi 0.80.7 invariant documented at ThreadController.ts:593
event.entry.type TypeError under server/client version skew (older /node mapper strips the payload) ✅ Fixed in e6cebc2event.entry?.type !== "custom", with a dedicated test for the missing-payload case
Cubic follow-up: PiUnknownSessionEntry in the union destroys narrowing for known variants ✅ Addressed in 80fdb39 — known variants keep their closed union; the guard restores narrowing over PiAnySessionEntry, verified by expectTypeOf tests

No new bugs introduced by the fixes. The refresh path coalesces via the existing loadPromise guard (ThreadController.ts:386), so the retained non-custom refresh can't storm getThread().

Critical issues

None found.

Suggestions

Two minor items posted inline:

  • node/mapping.ts:66toPiSessionEntry casts to the closed PiSessionEntry union while the event field is PiAnySessionEntry; casting to the open type (or inlining the cast like the neighboring assistantMessageEvent case) keeps the forward-compat story honest.
  • types.ts:243-261isKnownPiSessionEntry has no in-repo consumer and lands on the append-only public surface; worth either stating the consumer-facing intent in the PR body or deferring the export. Also a nit: repo convention is Object.hasOwn over Object.prototype.hasOwnProperty.call.

One note on the reducer: the explicit agent_settled/entry_appended cases in threadState.ts:373-375 are byte-identical to the default branch, and the package's other absorbed events (turn_start/turn_end) rely on the default. This was raised last round and keeping them is a defensible self-documenting choice — just noting it remains open rather than re-litigating.

Verification note

I 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

  • Version-skew test for a server that omits the entry payload
  • Mapper covered in both directions; type-level narrowing tests via expectTypeOf
  • Exhaustiveness-checked guard table; correct patch changeset; api-surface regenerated in the same PR

Simplify

The simplification pass found no critical issues; four minor cleanups, two of which are the inline comments above. The remaining two:

  • Redundant reducer cases (threadState.ts:373-375) — identical to the default branch below; deleting them is zero behavior change and matches the turn_start/turn_end precedent. Already discussed last round; author's call.
  • Guard export scope (types.ts:243-261) — the guard + table + its test file exist solely for a consumer that doesn't exist yet in-repo; see the inline comment for the trade-off.

Dismissed after checking: the event.entry?.type optional chain (justified defensive handling with a test), the api-surface diff (consistent generator output), and label?/name? shape differences (faithful SDK mirroring).

@greptile-apps

greptile-apps Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "feat(react-pi): map settled and appended..." | Re-trigger Greptile

Comment on lines +373 to +375
case "agent_settled":
case "entry_appended":
return stamped(state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 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.

@rupic-app rupic-app Bot added stage/awaiting-reviewer Has reviews but not approved; reviewer follow-up needed and removed stage/needs-review No reviews yet; waiting for a maintainer labels Jul 17, 2026
Comment on lines +179 to +235
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;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. Add a PiUnknownSessionEntry fallback variant (mirroring the PiAgentMessage pattern). Then type: string moves off PiSessionEntryBase and onto the fallback — as written the base's type: string is dead weight, since every variant intersects it with a literal.
  2. 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.

Fix this →

Comment on lines +373 to +375
case "agent_settled":
case "entry_appended":
return stamped(state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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().

Comment on lines +595 to +597
const needsSnapshotRefresh =
!KNOWN_EVENT_TYPES.has(event.type) ||
(event.type === "entry_appended" && event.entry.type !== "custom");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Comment on lines +373 to +375
case "agent_settled":
case "entry_appended":
return stamped(state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread api-surface/assistant-ui__react-pi.ts Outdated
Comment on lines +66 to +67
const toPiSessionEntry = (entry: SessionEntry): PiSessionEntry =>
entry as unknown as PiSessionEntry;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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):

Suggested change
const toPiSessionEntry = (entry: SessionEntry): PiSessionEntry =>
entry as unknown as PiSessionEntry;
const toPiSessionEntry = (entry: SessionEntry): PiAnySessionEntry =>
entry as unknown as PiAnySessionEntry;

Comment on lines +243 to +261
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,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two considerations on the guard:

  1. 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) checks entry?.type !== "custom" directly, and the sibling forward-compat unions (PiAgentMessage, PiAnyClientEvent) ship without an isKnown* 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 narrowing entry_appended payloads (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.

  2. Nit: the repo convention is Object.hasOwn(...) (used in packages/core, packages/ui, packages/react-generative-ui) rather than Object.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);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg/react-pi Changes a package under packages/ R0 Act now: stale, blocked, or high impact stage/awaiting-reviewer Has reviews but not approved; reviewer follow-up needed type/feature New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(react-pi): map pi's agent_settled and entry_appended events first-class

1 participant

Morty Proxy This is a proxified and sanitized view of the page, visit original site.