AI sessions: adopt pi-coding-agent SessionManager end-to-end#3360
Open
youknowriad wants to merge 14 commits intotrunkfrom
Open
AI sessions: adopt pi-coding-agent SessionManager end-to-end#3360youknowriad wants to merge 14 commits intotrunkfrom
youknowriad wants to merge 14 commits intotrunkfrom
Conversation
The pi runtime adoption (#3337) left behind a translation layer that synthesized Claude-Agent-SDK-shaped `SDKMessage` events from pi's native `AgentEvent`s, plus a sidecar JSON file that mirrored the agent's in-memory transcript next to the recorder's JSONL. Both were inherited from the pre-pi runtime — pi already gives us typed `AgentMessage`s and ships a full `SessionManager` (versioned JSONL, append-only tree, built-in v1→v3 migration, custom-entry hook). This collapses the indirection on the CLI side: * Runtime emits a small `AgentRuntimeEvent` union (pi's `AgentEvent` plus `run_started` / `compaction` / `turn_completed` / `runtime_error`). * Conversation transcript lives in pi's `SessionManager` — no separate sidecar. The runtime appends user/assistant/tool messages directly. * Studio metadata (site selection, paused turns, progress messages, agent questions, etc.) rides as typed `studio.*` `CustomEntry` payloads in the same JSONL. * Legacy `session.started v1` JSONL files migrate in place on load (`legacy-migration.ts`), so resumed sessions keep working. Net deletion: ~250 lines of `make*` builders in the runtime, the sidecar persistence module, the recorder class, the SDKMessage type, and three test files that were testing those legacy paths. Phase 2 (renderer + apps/studio + sdk-messages.ts shim) is a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Followups to the SessionManager adoption — trim the wrappers around pi
to keep the surface as close to native pi as possible.
* Shrink `tools/common/ai/sessions/entry-types.ts` from a full mirror of
pi's session types (~280 lines) to just Studio's own custom-entry
shapes plus a structural `SessionEntryBase` for renderer consumption.
CLI code imports `SessionEntry` and `AgentMessage` directly from pi.
* Collapse seven named `appendSiteSelected` / `appendToolProgress` /
etc. helpers into a single typed `appendStudioEntry<T>(sm, type, data)`
that's still discriminated through `StudioCustomEntryDataMap`.
* Replace `run_started` + `runtime_error` events with `turn_completed`
carrying the error in `result` — one fewer event type in the union and
consumers don't grow new branches.
* Rename our `compaction { phase }` event to match pi-coding-agent's
`AgentSessionEvent.compaction_start` / `compaction_end` shape so the
eventual `AgentSession` adoption is a drop-in.
* Drop the `lastResultSessionId` bookkeeping in commands/ai/index.ts —
pi's SessionManager hydrates the next turn from disk, so the retry
prompt is always "Continue from where you left off."
Net deletion: ~220 lines, no behavior change. Typecheck clean across
all 4 workspaces, 1487 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 left the storage layer asymmetric: the CLI runtime wrote pi-format JSONL but `tools/common/ai/sessions/store.ts` (used by apps/studio's IPC handlers and the renderer's sidebar) still parsed every line as a legacy `AiSessionEvent` and bailed when it didn't match — so any pi-format file (brand-new sessions, or legacy ones touched by the CLI's resume path) listed with empty `firstPrompt` / `ownerSitePath` / `eventCount`. The sidebar looked broken. This puts the migrator and a pi → legacy translator in tools/common so the read path is symmetric again: * `migration.ts` — the legacy → pi-format rewriter, parameterized on cwd (was hard-coded to `STUDIO_SITES_ROOT` in apps/cli). Uses structural shapes for pi entries so tools/common stays free of pi runtime imports. * `pi-translation.ts` — `piEntriesToLegacyEvents()` (used by the read path) and `legacyEventToPiEntries()` (used by `appendAiSessionEvent` when the on-disk file is pi format, e.g. for `setSessionEnvironment` and `setAiSessionModel` writes from the desktop IPC layer). * `store.ts` — `readAiSessionEventsFromFile` now migrates the file in place (no-op if already pi) then translates entries back to the legacy view downstream code expects. `createAiSession` writes pi format directly. `appendAiSessionEvent` detects format and dispatches. Disk truth is now consistently pi format (new sessions, migrated legacy sessions, and CLI runtime sessions all write the same shape). The `AiSessionEvent[]` type stays as the in-memory abstraction every reader consumes — the renderer doesn't need to change. Drops `LoadedAiSession.entries` (no longer needed; the `entries` field was a Phase-2 placeholder for direct pi-entry consumption that the read-path translator made unnecessary). Also reverts `apps/cli/ai/sessions/context.ts` to read legacy events again (now that those events flow correctly out of pi-format files). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oundary The Phase 2 storage layer (#3360) moved disk to pi format and added a pi↔legacy translator on the read/write path in tools/common. That made the sidebar work for new pi-format sessions, but at the cost of ~810 lines of bidirectional translation + a one-shot migrator. Reviewer feedback: "this feels complex, can't we just keep things simple?" Yes. Disk format goes back to the pre-Phase-1 legacy `AiSessionEvent[]` JSONL — unchanged across the entire history of this PR — and the pi↔ legacy bridge moves into the only place it's inherent: the CLI runtime, which is the sole consumer of pi `AgentMessage[]`. * Disk: legacy `AiSessionEvent[]` JSONL, exactly as before. * In-memory agent state: pi `Agent` (in `AGENTS_BY_SESSION` map keyed by sessionId), unchanged from Phase 1. * Hydration: on cold-start the runtime reads the legacy events from the session file and translates `sdk.message` events back into pi `AgentMessage[]` for `Agent.initialState.messages`. * Persistence: on `turn_end` the runtime translates the new pi assistant message + tool results into legacy `sdk.message` events and appends them via `fs.appendFile`. The orchestrator writes Studio metadata events (site selection, agent question, turn closed, etc.) directly via `appendAiSessionEvent` — no Studio-custom-entry layer at all. Net delete: ~700 lines (`migration.ts`, `pi-translation.ts`, `entry-types.ts`, `studio-entries.ts`, `pi-session.ts`). New: ~140-line `apps/cli/ai/runtimes/pi/messages.ts` covering both translation directions. Sidebar now populates correctly because the disk format never changed — no migration needed, no translator in `tools/common`. The summary / filter / active-site / store helpers go back to reading legacy events verbatim. The renderer (apps/ui) wasn't touched in either Phase 2 pass and continues to work unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "legacy disk + runtime translator" attempt left the CLI as
the only place that knew about the pi shape. This goes the other way:
the on-disk JSONL becomes pi-coding-agent's `SessionEntry`-based format
end-to-end, and every consumer (sidebar listing, session view, replay,
resume context, model resolution, optimistic mutation cache) reads pi
entries directly. There is no translator anywhere.
The migration is a one-shot eager sweep at app launch:
* `apps/studio/src/index.ts` `app.on('ready', ...)` calls
`migrateAllSessions(rootDir, cwd)` — walks `~/.studio/sessions/**`,
rewrites any legacy `session.started v1` JSONL into pi format in
place, and reports counts. Pi-format files are skipped, so the second
boot is a no-op.
* `apps/cli/commands/ai/index.ts` `runCommand()` runs the same sweep on
CLI entry so `studio code` from a fresh shell also catches anything
apps/studio missed.
* `apps/cli/ai/sessions/pi-session.ts` keeps a lazy fallback in
`openStudioSession` so an unmigrated file can still be opened
individually.
What changed downstream:
* `LoadedAiSession.entries: SessionEntryBase[]` replaces `events:
AiSessionEvent[]`. The `AiSessionEvent` union and
`tools/common/ai/sdk-messages.ts` are deleted entirely.
* `tools/common/ai/sessions/{summary,filter-events,active-site}.ts` and
`models.ts.resolveSessionModel` rewritten to consume pi entries —
they look at `studio.*` `customType` payloads and `model_change`
entries instead of legacy event types.
* `tools/common/ai/sessions/store.ts` writes pi format via `createAiSession`
and exposes `appendStudioEntry<T>` / `appendModelChangeEntry` for the
desktop IPC layer's `setSessionEnvironment` / `setAiSessionModel`
handlers (replacing `appendAiSessionEvent`).
* `apps/cli/ai/runtimes/pi/index.ts` goes back to persisting via
`SessionManager.appendMessage` — no per-turn legacy-event encoder. The
~140-line runtime translator (`messages.ts`) is deleted.
* `apps/cli/ai/sessions/{replay,context}.ts` consume pi entries from
`SessionManager.getEntries()` directly.
* `apps/cli/commands/ai/index.ts` writes Studio metadata as `studio.*`
custom entries via `appendStudioEntry`.
* `apps/ui/src/components/session-view/{conversation,composer,index}.tsx`
+ `apps/ui/src/data/queries/use-agent-run.ts` all switch to
`entries: SessionEntryBase[]`, with optimistic mutations producing pi
entries (synthetic ids; the next refetch on `run.exited` replaces them
with the disk-backed reals).
What we get back from this:
* Future-compat with pi-coding-agent's session features (compaction
summaries, branching, model_change history, bash-execution log).
* Phase 3 — adopting `AgentSession` from pi-coding-agent — becomes a
drop-in: pi already speaks our session shape. Drops `compaction.ts`
and gives us auto-retry / branching for free.
* `toolName` on pi `ToolResultMessage` is recorded properly (pi requires
it).
Validated: typecheck clean across all 4 workspaces, 1534 tests passing
(updated `create-session.test.ts` + `environment.test.ts` to assert
pi-format reads + custom-entry writes; `pi-runtime.test.ts` mocks
`SessionManager.inMemory()` via `vi.mock` with `importOriginal()` so the
real export reaches the runtime).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip the explanatory prose I sprinkled across the new files. Most of it was restating what the code already said. Net: -167 lines, no behavior change. Typecheck clean, 1534 tests passing.
The renderer persists query state to localStorage via @tanstack/query-sync-storage-persister. The previous build cached LoadedAiSession with `events: AiSessionEvent[]`; after Phase 2 it's `entries: SessionEntryBase[]`. Stale cache hydration left `prev.entries` undefined and crashed `entries.length` on empty/cached sessions. * Bump `buster: '1' → '2'` in apps/ui/src/data/core/query-client.ts so every persisted query is dropped on first load after deploy. * Defensive `data.entries ?? []` in conversation/index.tsx and `prev.entries ?? []` in use-agent-run.ts + composer/index.tsx so a malformed cache entry from any other source can't crash the same way.
youknowriad
commented
May 6, 2026
youknowriad
commented
May 6, 2026
- Drop eager migration sweep on app/cli boot — readPiFileEntries already migrates on first read, so the bulk pre-pass was redundant work that also blocked startup until every JSONL had been touched. - Stop re-declaring SessionEntry as a structural mirror; import the union from @mariozechner/pi-coding-agent directly. - Replace the runtime-emitted turn_completed envelope with pi's own agent_end. Consumers (CLI UI, JSON adapter, eval runner, pi-runtime test) now derive success/error from the last AssistantMessage's stopReason and count their own turns from turn_end events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collaborator
📊 Performance Test ResultsComparing d46f5d0 vs trunk app-size
site-editor
site-startup
Results are median values from multiple test runs. Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff) |
Drops our chars/4 estimator + synthetic-summary-as-user-message approach in favor of pi-coding-agent's compaction primitives. We now: - Trigger via pi's accurate token math (`calculateContextTokens` against the LLM's reported `usage`) and pi's overflow detector (`isContextOverflow` from pi-ai), instead of hand-rolling both. - Cut at proper turn boundaries via `findCutPoint` over `SessionEntry[]`, so tool-result chains and bash entries stay paired with their turn. - Generate the summary via `generateSummary` (with chained `previousSummary` for iterative compactions) and persist via `sessionManager.appendCompaction(...)` — the JSONL now records a real `CompactionEntry` instead of a synthetic user message. - Rebuild `agent.state.messages` from `sessionManager.buildSessionContext()` after compaction so the next request sees the trimmed transcript. - Run on `agent_end` (post-turn) plus a pre-flight check before each prompt — same dual trigger AgentSession uses. Overflow gets a compact-and-continue recovery via `agent.continue()`. - Emit the `compaction_start` / `compaction_end` shapes from pi's `AgentSessionEvent` (now carries `result: CompactionResult | undefined`) instead of declaring our own. System prompt control stays with us — we don't go through `AgentSession` because that would clobber `agent.state.systemPrompt` on every turn. Pi 0.70.2 doesn't export `prepareCompaction` or `estimateContextTokens` (used by AgentSession internally), so we walk session entries ourselves and skip pi's split-turn merge — when `findCutPoint` lands inside a turn, we round up to the turn start (one extra turn stays verbatim, no correctness issue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The studio-entries module was a 14-line file containing one 1-line wrapper used only by the CLI orchestrator. Moving it into commands/ai/index.ts as a local function removes one indirection without losing the type-safe `(customType, data)` signature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
youknowriad
commented
May 6, 2026
Cuts the auto-compaction footprint by ~320 lines. What's gone: - Pre-flight compaction before each `agent.prompt()`. A session resumed past the threshold now wastes one LLM call before the post-end check trims; rare and recoverable. - `agent.continue()` retry after an overflow compaction. On overflow the user sees the error once and re-prompts; the next request ships the compacted transcript and succeeds. - The `overflowRecoveryAttempted` state, cross-model overflow guard, stale-pre-compaction-usage guard, and the bespoke `CompactionDecision` discriminated union — all edge cases the simpler flow doesn't need. `auto-compaction.ts` is now a thin wrapper: `shouldCompact()` returns `'threshold' | 'overflow' | null` from pi's helpers, and `runCompaction()` does findCutPoint → generateSummary → appendCompaction → buildSessionContext. The pi/index.ts wiring is ~25 lines of compaction-event plumbing tucked into the existing agent.subscribe handler. `runtime-events.ts` collapses to a single re-export: `AgentRuntimeEvent = AgentSessionEvent`. No more hand-derived compaction event types. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracted `AiChatUI.renderToolResults(results)` from the live `turn_end` handler so replay can render tool results directly instead of constructing a fake assistant + synthetic `turn_end` event just to reuse the rendering path. Drops ~25 lines of placeholder ceremony in replay.ts and the redundant `as StudioCustomEntry<...>` casts after each `isStudioCustomEntryOfType()` guard (the guard already narrows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`runtime-events.ts` was reduced to a one-line alias (`AgentRuntimeEvent = AgentSessionEvent`) plus three derived types nobody imported. Delete the file and have the six consumers (output-adapter, eval-runner, ui, pi-runtime test, pi runtime, runtime types) import `AgentSessionEvent` straight from pi-coding-agent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rsistence
Four post-review fixes for the pi-migration PR.
1. /clear regression. `clearSession()` was only appending the
`studio.session_cleared` marker; the cached `Agent`'s
`state.messages` and the cold-resume `buildSessionContext()` both
ignored the marker, so the model still saw pre-clear turns.
- `pi/index.ts` exports `clearAgentForSession(sessionId)`; the
orchestrator drops the cache after appending the marker.
Cold-resume initial messages now flow through
`filterEntriesAfterLastClear` before `buildSessionContext`.
- Test in `pi-runtime.test.ts` asserts pre-clear text doesn't
reach the rebuilt agent.
2. Remote-session reply. `extractResultPayload` in
`turn-runner.ts` only matched the legacy SDK `result` envelope;
post-pi the wire carries `agent_end`, so `replyText` was always
undefined and Telegram fell back to "Local agent did not return a
result". Rewrote it to walk `agent_end.messages` for the last
assistant's text + `stopReason`. Updated the test fixture
(`mock-studio-code.mjs`) to emit pi-shape `agent_end` envelopes.
3. `--no-session-persistence` removal. The pi runtime requires a
live `SessionManager`, so the flag's only behavior post-PR was
"throw on every prompt". Dropped the option from
`apps/cli/index.ts`, `commands/ai/index.ts`, and
`commands/ai/sessions/resume.ts`; tightened `ensureSession()` to
return a non-undefined `SessionManager`.
4. Legacy Studio Code chat parser. `studio-code-event-parser.ts`
only recognised SDK shapes (`assistant`/`stream_event`/`user`/
`result`), so the chat went silent under the new wire format.
Migrated `parseSdkMessage` (now `parseSessionMessage`) to handle
`message_end` (assistant text → APPEND_TEXT, toolCall →
TOOL_USE_START) and `turn_end` (toolResults → TOOL_RESULT).
Envelope-level events (`turn.started`, `turn.completed`,
`progress`, `error`, `question.asked`) unchanged — they still
come from `JsonAdapter.emit*`. Added
`studio-code-event-parser.test.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issues
How AI was used in this PR
Refactor pair-programmed with Claude Code. Reviewed end to end before opening for review.
Proposed Changes
The pi-runtime adoption (#3337) left two layers of legacy plumbing inherited from the pre-pi runtime:
SDKMessageevents from pi's nativeAgentEvents (~250 lines ofmake*builders) so existing consumers could keep parsing the SDK shape.*.openai-state.jsonmirrored the agent's in-memory transcript next to the recorder's JSONL, because the JSONL itself stored synthetic-SDK events instead of native pi messages.This PR cuts both: the runtime emits a small
AgentRuntimeEventunion (pi events pluscompaction_start/compaction_end/turn_completed— shapes match pi-coding-agent'sAgentSessionEventso a futureAgentSessionswap is drop-in), and the on-disk JSONL is now pi-coding-agent'sSessionEntry-based format end to end. Studio metadata (site selection, agent questions, tool progress, etc.) rides asstudio.*CustomEntrypayloads in the same file.Migration: a one-shot eager sweep at app launch (
apps/studio/src/index.tsapp.on('ready')andapps/cli/commands/ai/index.tsrunCommandentry) walks~/.studio/sessions/**and rewrites any pre-pisession.started v1JSONL into pi format in place. Idempotent — pi-format files are skipped, so subsequent boots are no-ops. A lazy fallback inopenStudioSessioncatches any file the eager sweep missed.Consumers rewritten natively (no translator at any layer):
tools/common/ai/sessions/{summary,filter-events,active-site,store}.tsandmodels.ts.resolveSessionModelconsumestudio.*custom entries andmodel_changeentries directly.apps/cli/ai/sessions/{replay,context}.tsiterateSessionManager.getEntries().apps/cli/ai/runtimes/pi/index.tspersists viaSessionManager.appendMessage— no per-turn legacy-event encoder.apps/cli/commands/ai/index.tswrites Studio metadata viaappendStudioEntry<T>(sm, customType, data).apps/studioIPC writesmodel_changefor UI-driven/modelswaps viaappendModelChangeEntry(pi's native discriminator, not a custom entry) so resume-context resolution and pi's own session tooling both pick the override up.apps/ui/src/components/session-view/{conversation,composer,index}.tsxandapps/ui/src/data/queries/use-agent-run.tsconsume pi entries; optimistic mutations produce pi entries with synthetic ids that the nextrun.exitedrefetch replaces with disk-backed reals.Cache invalidation:
LoadedAiSession.events: AiSessionEvent[]becomesentries: SessionEntryBase[]. The renderer's@tanstack/react-query-persist-clientbusterkey is bumped so persisted localStorage caches from prior builds are dropped on first load after deploy.Deletions:
apps/cli/ai/runtimes/messages.ts(synthetic SDKMessage type)apps/cli/ai/runtimes/pi/persistence.ts(sidecar)apps/cli/ai/sessions/recorder.tstools/common/ai/sdk-messages.tsmcp__studio__prefix-stripping shim intools/common/ai/tools.tsWhat this unlocks (out of scope for this PR):
AgentSessionadoption from pi-coding-agent. The runtime event shapes already matchAgentSessionEventand disk format is pi-managed, so the swap dropsapps/cli/ai/runtimes/pi/compaction.ts(~130 lines) and gives us pi's auto-retry / branching / bash-execution queue for free. Estimated ~150 lines changed when we do it.model_changehistory now persist on disk via pi's session machinery (the legacy format couldn't represent them).Testing Instructions
~/.studio/sessions/<YYYY>/<MM>/<DD>/*.jsonl— the first line should change from{"type":"session.started","version":1,...}to{"type":"session","version":3,...}after first open.SessionManager.buildSessionContext()— there's no*.openai-state.jsonsidecar anymore.firstPrompt,ownerSitePath,activeEnvironment, etc. (the symptom of the read-side asymmetry that earlier iterations of this PR had — fixed because every reader now consumes pi entries natively).studio code --json "hello". Wire format keeps the legacy'message'envelope but the inner payload is the nativeAgentRuntimeEvent.AssistantMessage.errorMessagefor a 429 marker.data.entries ?? []guard.Pre-merge Checklist
🤖 Generated with Claude Code