From b996cd08a401eb696aba61a0b36d5b72a44c7cf7 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 26 Aug 2026 15:56:59 -0400 Subject: [PATCH 1/3] Adopt @ellipsis-dev/sdk 0.16.0 and seed stores via seedTranscriptStore --- bun.lock | 4 ++-- package.json | 2 +- src/commands/connect.ts | 28 +++++++++++----------------- src/ui/SessionsApp.tsx | 23 ++++++++++------------- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/bun.lock b/bun.lock index 419d1a3..0ddc58b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.15.1", + "@ellipsis-dev/sdk": "^0.16.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", @@ -35,7 +35,7 @@ "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], - "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.15.1", "", {}, "sha512-BbU9omjZwqLLL5rLluYkm4P8PMCNWlaRmfMjjZqazHY+SDlgn6s/Zfb5QD6MtT+H7Dyat2/hqA7CWAB7S84QXA=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.16.0", "", {}, "sha512-brj9VpVtKrfyjvCJegyaEvOjMcW5Czul4s4q99U20hE7/Bver+WbId0yM8PC8G4n3ZS6SgJPAHBR3u8+jIoegA=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/package.json b/package.json index f2aec67..92fa6eb 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.15.1", + "@ellipsis-dev/sdk": "^0.16.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/src/commands/connect.ts b/src/commands/connect.ts index b625c57..d2a647e 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -1,8 +1,7 @@ import type { Command } from 'commander' import React from 'react' import { render } from 'ink' -import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' -import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' +import { SessionTranscriptStore, seedTranscriptStore } from '@ellipsis-dev/sdk/store' import { api } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' import { runAction } from '../lib/output' @@ -118,26 +117,21 @@ export async function runConnect( // The footer carries the session identity/status; a watch-only reason // surfaces as the app's notice. - // Seed ONE transcript store with the stored records and the fetched session - // — synthetic frames through the same ingest path the live stream uses, so - // the first paint is instant and streamSession resumes past the seeded + // Seed ONE transcript store with the stored records and the fetched session, + // so the first paint is instant and streamSession resumes past the seeded // cursor instead of replaying history. --no-records skips *rendering* the // seeded history (minRenderFeedSeq), not re-streaming it. const store = new SessionTranscriptStore() const page = (await client.sessions.records(sessionId)).response - const ordered = [...page.records].sort((a, b) => a.feed_seq - b.feed_seq) - // Seed the session + open inbox as a synthetic snapshot frame (protocol v3: - // the store folds the inbox from the snapshot projection and the message_* - // records that ride the feed), then replay the records to advance the cursor - // so streamSession resumes past the seeded history rather than re-replaying. - store.ingest({ - type: 'snapshot', - protocol: SESSION_STREAM_PROTOCOL_VERSION, - earliest_feed_seq: page.earliest_feed_seq ?? null, + // The cast bridges the SDK's two generated flavors of the same wire shape: + // REST responses mark nullable fields optional, the frame types require + // them. Identical JSON either way. + seedTranscriptStore(store, { session, - messages: page.messages ?? [], - }) - if (ordered.length) store.ingest({ type: 'records_append', records: ordered }) + records: page.records, + messages: page.messages, + earliestFeedSeq: page.earliest_feed_seq, + } as Parameters[1]) // Written by the app when it exits because the conversation closed (terminal; // nothing left to reconnect to), so the detach sign-off below stays honest. diff --git a/src/ui/SessionsApp.tsx b/src/ui/SessionsApp.tsx index 4985ce4..3ea6616 100644 --- a/src/ui/SessionsApp.tsx +++ b/src/ui/SessionsApp.tsx @@ -1,8 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Box, Text, useInput, useStdin, useStdout } from 'ink' import type { OpenSocket } from '@ellipsis-dev/sdk/stream' -import { SESSION_STREAM_PROTOCOL_VERSION } from '@ellipsis-dev/sdk/stream' -import { SessionTranscriptStore } from '@ellipsis-dev/sdk/store' +import { SessionTranscriptStore, seedTranscriptStore } from '@ellipsis-dev/sdk/store' import type { Ellipsis } from '@ellipsis-dev/sdk' import { errorDetail } from '../lib/api' import type { @@ -224,9 +223,8 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { const [loadError, setLoadError] = useState(null) const loading = useRef(new Set()) - // Seed a transcript store exactly like the solo connect: a synthetic - // snapshot frame (session + open inbox) then the stored records, so the - // first paint is instant and the stream resumes past the seeded cursor. + // Seed a transcript store exactly like the solo connect, so the first + // paint is instant and the stream resumes past the seeded cursor. const loadEntry = useCallback( async (sessionId: string, configName?: string, notice?: string): Promise => { if (loading.current.has(sessionId)) return @@ -238,15 +236,14 @@ export function SessionsApp(props: SessionsAppProps): React.ReactElement { api.sessions.records(sessionId).then((p) => p.response), ]) const store = new SessionTranscriptStore() - const ordered = [...page.records].sort((a, b) => a.feed_seq - b.feed_seq) - store.ingest({ - type: 'snapshot', - protocol: SESSION_STREAM_PROTOCOL_VERSION, - earliest_feed_seq: page.earliest_feed_seq ?? null, + // Same cast as runConnect: REST marks nullable fields optional, the + // frame types require them. Identical JSON either way. + seedTranscriptStore(store, { session, - messages: page.messages ?? [], - }) - if (ordered.length) store.ingest({ type: 'records_append', records: ordered }) + records: page.records, + messages: page.messages, + earliestFeedSeq: page.earliest_feed_seq, + } as Parameters[1]) const c = connectability(session) const entry: ChatEntry = { store, From 30268f68b04ed67d456f5f1cb93d85b8a8c7a8a8 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 26 Aug 2026 16:03:55 -0400 Subject: [PATCH 2/3] Use the SDK's shared session derivations in ConnectApp --- src/lib/steps.ts | 36 ++- src/ui/ConnectApp.tsx | 483 ++------------------------------------- test/connect-app.test.ts | 445 ++---------------------------------- 3 files changed, 67 insertions(+), 897 deletions(-) diff --git a/src/lib/steps.ts b/src/lib/steps.ts index 4be53d2..9315c6e 100644 --- a/src/lib/steps.ts +++ b/src/lib/steps.ts @@ -1,4 +1,10 @@ -import { lifecycleText as sdkLifecycleText, oneLine } from '@ellipsis-dev/sdk/store' +import { + deriveSandboxState as sdkDeriveSandboxState, + lifecycleText as sdkLifecycleText, + sessionLogText as sdkSessionLogText, + oneLine, + type SandboxState, +} from '@ellipsis-dev/sdk/store' import { formatTs } from './output' import type { SessionRecord } from './types' @@ -6,12 +12,34 @@ import type { SessionRecord } from './types' // importers; the implementations live in the SDK's store layer now. export { oneLine, sandboxOutputStep, sandboxOutputLine } from '@ellipsis-dev/sdk/store' -// The SDK's lifecycle wording, with its middot separators as commas — the CLI -// writes plain sentences. +// The SDK's wording, with its middot separators as commas — the CLI writes +// plain sentences. +const commas = (text: string): string => text.replaceAll(' · ', ', ') + export function lifecycleText( ...args: Parameters ): string | null { - return sdkLifecycleText(...args)?.replaceAll(' · ', ', ') ?? null + const text = sdkLifecycleText(...args) + return text === null ? null : commas(text) +} + +export function sessionLogText( + ...args: Parameters +): string | null { + const text = sdkSessionLogText(...args) + return text === null ? null : commas(text) +} + +export function deriveSandboxState( + ...args: Parameters +): SandboxState | null { + const state = sdkDeriveSandboxState(...args) + if (!state) return null + return { + ...state, + headline: commas(state.headline), + log: state.log.map((line) => ({ ...line, text: commas(line.text) })), + } } // Record-rendering helpers shared by `session records` and `session connect` diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 8035eca..bcdf5c0 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -14,19 +14,23 @@ import { type OpenSocket, } from '@ellipsis-dev/sdk/stream' import { - cacheTierLabel, + awaitingAgentPhase, collapseToolRuns, + deliveredUnechoedSends, foldCosts, + humanDuration, + lastLines, pendingToolCalls, + recordSlice, recordToItems, - sandboxOutputLines, - sandboxOutputStep, - sandboxPhaseLabel, + sandboxSummary, statusActivityText, + type RecordSlice, + type SandboxState, type SessionTranscriptStore, type TranscriptItem, } from '@ellipsis-dev/sdk/store' -import { lifecycleText } from '../lib/steps' +import { deriveSandboxState, sessionLogText } from '../lib/steps' import { errorDetail } from '../lib/api' import type { Ellipsis, SdkRecord, SessionRecord } from '@ellipsis-dev/sdk' import { hyperlink } from '../lib/urls' @@ -306,7 +310,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // Each turn's closing duration/cost summary is dropped too (see // reshapeTranscript) — the footer carries the session's spend. const { items, undisplayed } = useMemo(() => { - const shaped = reshapeTranscript(slice(snapshot.records), props.minRenderFeedSeq) + const shaped = reshapeTranscript(recordSlice(snapshot.records), props.minRenderFeedSeq) // Client-side notes land at the end: they describe what you just did, so // they belong under everything the server has sent so far. for (const note of chatNotes) { @@ -342,7 +346,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // as the durable trace (the sandbox_ready transcript notice is suppressed // below in its favour). const sandbox = useMemo( - () => deriveSandboxState(slice(snapshot.records), props.minRenderFeedSeq), + () => deriveSandboxState(recordSlice(snapshot.records), props.minRenderFeedSeq), [snapshot.records, props.minRenderFeedSeq], ) // Bodies of the server's PENDING inbox messages — the durable queued signal. @@ -492,13 +496,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // Which silence a started-but-quiet turn is in ('boot': Claude Code is // still starting in the sandbox; 'turn': the warm agent between records), // for the fallback live line's label. - const awaitingAgent = useMemo(() => awaitingAgentPhase(slice(snapshot.records)), [snapshot.records]) + const awaitingAgent = useMemo(() => awaitingAgentPhase(recordSlice(snapshot.records)), [snapshot.records]) // Sends the agent took mid-gap: delivered to the agent but its user-echo // transcript record hasn't landed yet (the echo can lag by a whole sandbox // wake). Rendered as full-colour user rows until the echo replaces them. const acceptedSends = useMemo( - () => deliveredUnechoedSends(slice(snapshot.records)), + () => deliveredUnechoedSends(recordSlice(snapshot.records)), [snapshot.records], ) @@ -1443,22 +1447,6 @@ function sandboxRows(o: { return rows } -// The whole start compressed to one line, for the settled block: how long the -// sandbox took. Falls back to "Sandbox started" when no timing can be derived — -// an old feed whose sandbox_ready carried no phase_timings, or a wake, where the -// duration was never ours to know. Pure, for tests. -export function sandboxSummary(sandbox: SandboxState | null): string { - const seconds = sandbox?.readySeconds ?? null - return seconds ? `Sandbox ready in ${humanDuration(seconds)}` : 'Sandbox started' -} - -// The tail of the startup log: the last `max` lines, which is what you want -// while a session comes up — the newest output, not the oldest. Pure, for -// tests. -export function lastLines(log: readonly SandboxLogLine[], max: number): SandboxLogLine[] { - return log.length <= max ? [...log] : log.slice(log.length - max) -} - // A single line, truncated to `width` visible columns — the startup block's // lines are structural (indent + mark + label), so an over-long one is cut // rather than reflowed onto a row the layout didn't account for. @@ -1466,94 +1454,12 @@ function fit(text: string, width: number): string { return fitLines(text, Math.max(4, width))[0] ?? '' } -// A sandbox_output step identifier — payload.step ?? payload.phase — as a -// human startup-phase label. Steps are null/'post_start'/'post_clone' and -// phases 'setup'/'clone'/'hooks'; 'image.setup' is the legacy image step. -// Unknown values pass through verbatim (§3.6). -export function hookPhrase(step: string): string { - switch (step) { - case 'setup': - case 'image.setup': - return 'Building image' - case 'clone': - return 'Fetching repositories' - case 'post_start': - return 'Post-start setup' - case 'post_clone': - return 'Post-clone setup' - default: - return step - } -} - // Lines of the startup log the block shows: the last ten, which is enough to // watch an image build or a setup hook make progress without the block taking // over the chat window. Anything older is counted in the "… +N earlier lines" // head above them. const SANDBOX_LOG_ROWS = 10 -// One line of the startup log: a milestone (a phase opening or closing, the -// config resolving, the box coming up) or a line of output from whatever the -// sandbox was running. They all live in ONE flat list in feed order, because -// that is how they happened and how you read them. -export type SandboxLogKind = 'step' | 'output' | 'done' | 'failed' -export type SandboxLogLine = { - key: string - kind: SandboxLogKind - text: string -} - -// The startup story as a HEADLINE plus a FLAT LOG. -// -// It used to be a three-level tree (session → sandbox → phases → each phase's -// own log tail), drilled into with →. That shape hid the thing you actually -// want when a session is slow to come up — the build output — three keystrokes -// deep, and it split one chronological story across separate per-phase tails. -// Now every milestone and every line of build/setup output goes into one -// ordered list, and the block shows the LAST few (SANDBOX_LOG_ROWS) of it. -export type SandboxState = { - // The current LIVE top-level line ("Session scheduled…", "Starting cloud - // agent…", "Waking the session…", "Retrying…"). Read only while the block is - // still moving: once it settles the block shows its static summary instead, - // so a session that later falls asleep doesn't rewrite its own opening line. - headline: string - done: boolean - // How long the sandbox took to come up, when the feed says (sandbox_ready's - // phase_timings). null on a wake or an old feed that carried no timings — the - // settled summary drops the duration rather than inventing one. - readySeconds: number | null - // Whether the sandbox itself has finished provisioning, so the log's live - // lines stop pulsing. - sandboxDone: boolean - // The agent config resolved at scheduling, held apart from the log because it - // outlives a restart: the log drops on a retry/wake, but which config the - // session runs is still true. Rendered as the log's first line. - configName: string | null - // The commit of the config file in the repo it's owned at (the sync - // provenance), when the backend sends it. Shortened for display. - configCommitSha: string | null - // Everything that happened during this start, oldest first. - log: SandboxLogLine[] -} - -// The structural slice of a session record the derivations below need. The SDK -// types each harness's payload as its own union (claude_sdk@1, codex_jsonl@1, -// ellipsis_lifecycle@1) with no index signature, and these functions only ever -// read display fields by name across all three — so they take the slice, and -// the cast happens once, in `slice` below. -type LifecycleRecordLike = { - feed_seq: number - source: string - record_type: string - payload: Record - // The inbox message a user-echo transcript record answers for (§3.3). - session_message_id?: string | null -} - -function slice(records: readonly SessionRecord[]): readonly LifecycleRecordLike[] { - return records as readonly LifecycleRecordLike[] -} - // The chat is a LOG of the session: what was said, and what happened to the // session while it was being said. So the milestones — it went to sleep, it is // waking again, it was cancelled — land in the transcript, in feed order, @@ -1571,7 +1477,7 @@ function slice(records: readonly SessionRecord[]): readonly LifecycleRecordLike[ // over: no row, and no hint that a row is missing. Init events are excluded: // they are deliberately silent here. export function reshapeTranscript( - records: readonly LifecycleRecordLike[], + records: readonly RecordSlice[], minRenderFeedSeq: number, ): { items: TranscriptItem[]; undisplayed: number } { const items: TranscriptItem[] = [] @@ -1589,7 +1495,7 @@ export function reshapeTranscript( wakeAt = -1 continue } - const text = sessionLogText(r) + const text = sessionLogText(r.record_type, r.payload) if (text) { items.push({ key: `s${r.feed_seq}`, kind: 'notice', text, spaceBefore: true }) wakeAt = text === 'Waking the session…' ? items.length - 1 : -1 @@ -1623,365 +1529,6 @@ export function reshapeTranscript( return { items, undisplayed } } -// The session milestones worth a line in the chat log, and how each reads. -// Deliberately a SHORT list of state changes a reader would otherwise be left -// guessing about: -// - the session parked between turns -// - it is coming back up (a wake, or an infra retry after a wobble) -// - it was stopped or cancelled -// Everything else the lifecycle feed carries is startup detail (sandbox phases, -// setup log chunks, per-phase timings) and belongs to the startup block up top, -// not the conversation — logging it would bury the chat in provisioning noise. -// -// A wake is ONE line, not two: "Waking the session…" is the same event as -// "Session awake" a few seconds later, so reshapeTranscript settles the waking -// line in place rather than adding a second row under it. -// -// `session_ready`-style milestones are deliberately absent for a FIRST start: -// the startup block already tells that story in place. A wake is different — -// it happens long after the block settled, mid-conversation. Pure, for tests. -export function sessionLogText(record: LifecycleRecordLike): string | null { - const p = record.payload - switch (record.record_type) { - case 'session_idle': - return 'Session asleep' - case 'session_starting': { - // Only a WAKE is logged: the first start is the startup block's story. - const wake = typeof p.wake_index === 'number' ? p.wake_index : 0 - const attempt = typeof p.attempt === 'number' ? p.attempt : 0 - if (attempt > 0) return 'Restarting the sandbox after a transient error…' - return wake > 0 ? 'Waking the session…' : null - } - case 'session_retrying': - return typeof p.reason === 'string' && p.reason - ? `Retrying, ${p.reason}` - : 'Retrying after a transient error…' - case 'session_resumed': - return 'Session awake' - case 'session_cancelled': { - const reason = typeof p.reason === 'string' && p.reason ? `, ${p.reason}` : '' - return `Session cancelled${reason}` - } - default: - return null - } -} - -// Whether a turn is IN FLIGHT (a turn_started record without its -// turn_completed/turn_failed), and which silence it is: 'boot' when the -// harness has emitted NOTHING this execution — Claude Code is still starting -// up in the sandbox, the ~15-20s dead air after a send lands a fresh -// execution's first turn — vs 'turn', a running turn's lull between records. -// null when no turn is in flight, which INCLUDES the bare interactive -// session sitting at 'working' status waiting for its first message (no -// turn, no Claude Code process — nothing to narrate). Drives the fallback -// live line so a send never looks like the app hung. Pure, for tests. -export function awaitingAgentPhase( - records: readonly LifecycleRecordLike[], -): 'boot' | 'turn' | null { - let inFlight = false - let sawAgent = false - for (const r of records) { - if (r.source === 'claude_code') { - sawAgent = true - } else if (r.source === 'lifecycle') { - if (r.record_type === 'turn_started') inFlight = true - else if (r.record_type === 'turn_completed' || r.record_type === 'turn_failed') { - inFlight = false - } else if ( - r.record_type === 'session_starting' || - r.record_type === 'session_retrying' - ) { - // A fresh execution: no turn is in flight and the harness must boot - // again before it speaks. - inFlight = false - sawAgent = false - } - } - } - if (!inFlight) return null - return sawAgent ? 'turn' : 'boot' -} - -// Sends the agent has TAKEN but not yet echoed into the transcript: each -// message_received body, walked through delivered/requeued transitions, minus -// the ids whose user-echo record (session_message_id back-reference) has -// landed. The store's pending set drops a message the instant it's delivered, -// but the agent's echo record can lag by a whole sandbox wake — without this -// bridge a send flashes and vanishes for the gap. Rendered as full-colour -// user rows at the transcript's bottom edge (the mid-turn send is part of the -// running turn, Claude Code-style). -// -// `cancelled` means the turn that took the message DIED without answering it — -// the /stop path, where the backend deliberately does not requeue an -// interrupted turn's messages (the message is consumed, the answer never -// comes). Rendered "cancelled" rather than left breathing forever, which is the -// bug this distinction fixes. A message_requeued instead puts the message back -// in the inbox, so it is queued again, not cancelled. Pure, for tests. -export function deliveredUnechoedSends( - records: readonly LifecycleRecordLike[], -): { id: string; body: string; cancelled: boolean }[] { - const received = new Map() - // Message id -> the turn that consumed it, for the turn_failed correlation. - const delivered = new Map() - const failedTurns = new Set() - const echoed = new Set() - for (const r of records) { - if (r.session_message_id != null) echoed.add(r.session_message_id) - if (r.source !== 'lifecycle') continue - if (r.record_type === 'turn_failed') { - if (typeof r.payload.turn_id === 'string') failedTurns.add(r.payload.turn_id) - continue - } - const id = typeof r.payload.message_id === 'string' ? r.payload.message_id : null - if (!id) continue - if (r.record_type === 'message_received') { - if (!received.has(id)) - received.set(id, typeof r.payload.body === 'string' ? r.payload.body : '') - } else if (r.record_type === 'message_delivered') { - delivered.set(id, typeof r.payload.turn_id === 'string' ? r.payload.turn_id : '') - } else if (r.record_type === 'message_requeued') delivered.delete(id) - } - const out: { id: string; body: string; cancelled: boolean }[] = [] - for (const [id, body] of received) { - const turnId = delivered.get(id) - if (turnId === undefined || echoed.has(id)) continue - out.push({ id, body, cancelled: failedTurns.has(turnId) }) - } - return out -} - -// A duration in seconds as compact human-readable components. Precision -// scales down with size: under 1s reads as milliseconds ("428ms"), under 5s -// keeps one decimal ("1.2s", trimming a trailing .0), and everything longer -// reads as whole h/m/s components with zero parts dropped ("10s", "1m 2s", -// "2m", "1h 3m 30s"). The one duration format everywhere in the app, and it -// reads bare — a readout, not a parenthetical aside. Pure, for tests. -export function humanDuration(seconds: number): string { - const clamped = Math.max(0, seconds) - if (clamped === 0) return '0s' - if (clamped < 1) return `${Math.round(clamped * 1000)}ms` - if (clamped < 5) { - const s = clamped.toFixed(1) - return s.endsWith('.0') ? `${Math.round(clamped)}s` : `${s}s` - } - const total = Math.round(clamped) - const h = Math.floor(total / 3600) - const m = Math.floor((total % 3600) / 60) - const s = total % 60 - const bits: string[] = [] - if (h > 0) bits.push(`${h}h`) - if (m > 0) bits.push(`${m}m`) - if (s > 0 || bits.length === 0) bits.push(`${s}s`) - return bits.join(' ') -} - -function msLabel(ms: unknown): string | null { - if (typeof ms !== 'number' || !isFinite(ms) || ms < 0) return null - return humanDuration(ms / 1000) -} - -// The image phase's provisioning sub-steps as sentences: the Modal -// dockerfile build, the Sandbox.create container start (minutes for a -// multi-GB image), and the post-create smoke test. The step vocabulary is -// open by contract, so unknown steps pass through verbatim. -function imageStepLabel(step: string): string { - switch (step) { - case 'build': - return 'Building image' - case 'container': - return 'Starting container' - case 'smoke': - return 'Smoke check' - default: - return step - } -} - -// Human label for a timeline step: hooks sub-items keep their hook phrasing, -// image sub-items read as sentences, other sub-items (a clone's -// "owner/repo") read as themselves, whole phases go through the SDK's -// open-vocabulary phase labels. -function stepLabel(phase: string, step: string | null): string { - if (step) { - if (phase === 'hooks') return hookPhrase(step) - if (phase === 'image') return imageStepLabel(step) - return step - } - return sandboxPhaseLabel(phase) -} - -// The startup story from the lifecycle records of the LATEST start: a live -// headline for while it is still coming up ("Session scheduled…" → "Starting -// cloud agent…" / "Waking…" / "Retrying…"), plus ONE FLAT LOG of everything that -// happened on the way up, in feed order — the config resolving, each -// provisioning phase opening and closing (with its cache tier and duration), and -// every line of output those phases produced (image builds, clones, setup -// hooks). -// -// The headline tracks the START only, never later session status: session_idle -// is a mid-conversation event with its own transcript line, and folding it in -// here made an old session open with "Session asleep" as its first line. -// -// session_starting begins a fresh story: a wake or an infra retry drops the -// previous start's log rather than appending to it. null when no lifecycle -// record has been seen. Pure, for tests. -export function deriveSandboxState( - records: readonly LifecycleRecordLike[], - minFeedSeq: number, -): SandboxState | null { - let seen = false - let headline = 'Starting cloud agent…' - let done = false - let sandboxDone = false - let readySeconds: number | null = null - let configName: string | null = null - let configCommitSha: string | null = null - let log: SandboxLogLine[] = [] - // Phases still open, so a `completed`/`failed` transition can close the line - // it opened rather than adding a second one. - let open = new Map() - const push = (record: LifecycleRecordLike, kind: SandboxLogKind, text: string): SandboxLogLine => { - const entry = { key: `${record.feed_seq}:${log.length}`, kind, text } - log.push(entry) - return entry - } - const reset = (): void => { - log = [] - open = new Map() - sandboxDone = false - } - - for (const record of records) { - if (record.feed_seq <= minFeedSeq || record.source !== 'lifecycle') continue - const p = record.payload - switch (record.record_type) { - case 'session_scheduled': { - seen = true - headline = 'Session scheduled…' - done = false - configName = typeof p.config_name === 'string' && p.config_name ? p.config_name : null - configCommitSha = - typeof p.config_commit_sha === 'string' && p.config_commit_sha - ? p.config_commit_sha - : null - break - } - case 'session_starting': - case 'session_retrying': { - seen = true - // Every claim starts a fresh story: the headline takes over and the - // previous start's log drops. A fresh first start is the one line the - // SDK's wording ("Session starting…") doesn't match — this block is - // about a CLOUD AGENT coming up, and that is worth saying once. - const text = lifecycleText(record.record_type, p) - headline = !text || text === 'Session starting…' ? 'Starting cloud agent…' : text - done = false - readySeconds = null - reset() - break - } - case 'session_resumed': - case 'session_idle': { - seen = true - // Both settle the block WITHOUT touching the headline: the wake mounted - // its snapshots, or the session parked between turns. Either way the - // start is over, and the chat log carries the event on its own line. - done = true - break - } - case 'sandbox_starting': { - seen = true - reset() - push(record, 'step', 'Starting sandbox…') - break - } - case 'sandbox_phase': { - seen = true - const phase = typeof p.phase === 'string' && p.phase ? p.phase : 'setup' - const step = typeof p.step === 'string' && p.step ? p.step : null - const key = step ? `${phase}:${step}` : phase - const label = stepLabel(phase, step) - if (p.status === 'completed' || p.status === 'failed') { - const detail = - p.detail && typeof p.detail === 'object' ? (p.detail as Record) : {} - // "Preparing image, full build, 2s" — the label then its readout, - // comma-separated like every other metadata line in the app. - const tier = cacheTierLabel(detail.cache_tier) - const dur = msLabel(p.duration_ms) - const failed = p.status === 'failed' - const base = failed ? `${label} failed` : label - const text = [base, ...(tier ? [tier] : []), ...(dur ? [dur] : [])].join(', ') - const line = open.get(key) - if (line) { - // Close the line this phase opened, in place: one line per phase, - // not an opening line and a closing one. - line.kind = failed ? 'failed' : 'done' - line.text = text - open.delete(key) - } else { - push(record, failed ? 'failed' : 'done', text) - } - } else if (!open.has(key)) { - open.set(key, push(record, 'step', `${label}…`)) - } - break - } - case 'sandbox_output': { - seen = true - for (const l of sandboxOutputLines(p)) push(record, 'output', l) - break - } - case 'sandbox_ready': { - seen = true - // Anything still open finished when the box came up. - for (const [, line] of open) line.kind = 'done' - open = new Map() - const timings = - p.phase_timings && typeof p.phase_timings === 'object' - ? Object.values(p.phase_timings as Record) - : [] - const totalSeconds = timings.reduce( - (acc, v) => (typeof v === 'number' && isFinite(v) ? acc + v : acc), - 0, - ) - const tier = cacheTierLabel(p.cache_tier) - push( - record, - 'done', - [ - 'Sandbox ready', - ...(tier ? [tier] : []), - ...(totalSeconds > 0 ? [humanDuration(totalSeconds)] : []), - ].join(', '), - ) - sandboxDone = true - readySeconds = totalSeconds > 0 ? totalSeconds : null - // The box coming up is the session-level outcome too. - done = true - break - } - default: - break - } - } - // The config line heads the log: it is the first thing that was decided, and - // it survives the restarts that clear everything below it. - const full: SandboxLogLine[] = configName - ? [ - { - key: 'config', - kind: 'done', - text: `Using ${configName}${configCommitSha ? ` @ ${configCommitSha.slice(0, 7)}` : ''}`, - }, - ...log, - ] - : log - return seen - ? { headline, done, readySeconds, sandboxDone, configName, configCommitSha, log: full } - : null -} - // One screen row. Exactly one terminal line by construction: the text was // pre-fitted to the pane (see transcriptRows), and wrap="truncate" is the // belt-and-braces guarantee — a row that wrapped would push every row below it diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index e1372e7..0248c61 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -1,17 +1,6 @@ import { describe, expect, it } from 'vitest' -import { - awaitingAgentPhase, - cursorLineDown, - cursorLineUp, - deliveredUnechoedSends, - deriveSandboxState, - lastLines, - hookPhrase, - humanDuration, - reshapeTranscript, - sandboxSummary, - sessionLogText, -} from '../src/ui/ConnectApp' +import { cursorLineDown, cursorLineUp, reshapeTranscript } from '../src/ui/ConnectApp' +import { deriveSandboxState, sessionLogText } from '../src/lib/steps' import { gutterFor, itemRows, @@ -40,392 +29,52 @@ function rec(recordType: string, payload: Record = {}, source = } } -describe('deriveSandboxState', () => { - // The whole startup story is ONE flat log, in feed order — the shape that - // replaced the old session → sandbox → phase → per-phase-tail tree. +// The derivations themselves (deriveSandboxState, awaitingAgentPhase, +// deliveredUnechoedSends, lastLines, humanDuration, hookPhrase, …) are the +// SDK's now, tested there with its middot wording. What the CLI owns is the +// comma wording: src/lib/steps.ts wraps the SDK functions and swaps ' · ' +// separators for ', ', so the terminal reads plain sentences. +describe('deriveSandboxState comma wording', () => { const texts = (state: ReturnType) => (state?.log ?? []).map((l) => l.text) - const kinds = (state: ReturnType) => - (state?.log ?? []).map((l) => l.kind) - - it('returns null before any lifecycle record', () => { - expect(deriveSandboxState([], 0)).toBeNull() - expect(deriveSandboxState([rec('assistant', {}, 'claude_code')], 0)).toBeNull() - }) - - it('walks the live headline: scheduled → starting → done', () => { - const scheduled = deriveSandboxState([rec('session_scheduled', { source: 'cli' })], 0) - expect(scheduled?.headline).toBe('Session scheduled…') - expect(scheduled?.done).toBe(false) - - const starting = deriveSandboxState( - [ - rec('session_scheduled', { source: 'cli', config_name: 'my-agent' }), - rec('session_starting', { attempt: 0, wake_index: 0 }), - ], - 0, - ) - expect(starting?.headline).toBe('Starting cloud agent…') - expect(starting?.done).toBe(false) - const ready = deriveSandboxState( - [ - rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('sandbox_starting', {}), - rec('sandbox_ready', { cache_tier: 'exact' }), - ], - 0, - ) - expect(ready?.done).toBe(true) - expect(ready?.sandboxDone).toBe(true) - }) - - it('heads the log with the config, and keeps it across the starting transition', () => { - const state = deriveSandboxState( - [ - rec('session_scheduled', { - source: 'cli', - config_name: 'deployer', - config_commit_sha: 'abc1234def5678', - }), - rec('session_starting', { attempt: 0, wake_index: 0 }), - ], - 0, - ) - // The config outlives the restart that clears the log below it. - expect(state?.headline).toBe('Starting cloud agent…') - expect(state?.configName).toBe('deployer') - expect(texts(state)[0]).toBe('Using deployer @ abc1234') - }) - - it('logs each phase as ONE line, opened then closed in place', () => { + it('writes phase readouts comma-separated, not middot-separated', () => { const state = deriveSandboxState( [ rec('sandbox_starting', { repositories: ['o/r'] }), - rec('sandbox_phase', { phase: 'image', status: 'started' }), rec('sandbox_phase', { phase: 'image', status: 'completed', duration_ms: 1200, detail: { cache_tier: 'exact' }, }), - rec('sandbox_phase', { phase: 'clone', status: 'started' }), - ], - 0, - ) - // Not "Preparing image…" AND "Preparing image ✓" — the same line closes. - expect(texts(state)).toEqual([ - 'Starting sandbox…', - 'Preparing image, cached image, 1.2s', - 'Fetching repositories…', - ]) - expect(kinds(state)).toEqual(['step', 'done', 'step']) - }) - - it('puts build and setup OUTPUT in the same flat log, in order', () => { - const state = deriveSandboxState( - [ - rec('sandbox_phase', { phase: 'image', step: 'build', status: 'started' }), - rec('sandbox_output', { phase: 'image', step: 'build', chunk: 0, lines: ['#1 FROM base'] }), - rec('sandbox_output', { phase: 'image', step: 'build', chunk: 1, lines: ['#2 RUN npm ci'] }), - rec('sandbox_phase', { - phase: 'image', - step: 'build', - status: 'completed', - duration_ms: 42000, - }), - rec('sandbox_phase', { phase: 'hooks', step: 'post_clone', status: 'started' }), - rec('sandbox_output', { phase: 'hooks', step: 'post_clone', chunk: 0, lines: ['npm ci'] }), - ], - 0, - ) - // This is the point of the flat log: the output you want while a session - // is slow to start is right there, not three keystrokes deep. - expect(texts(state)).toEqual([ - 'Building image, 42s', - '#1 FROM base', - '#2 RUN npm ci', - 'Post-clone setup…', - 'npm ci', - ]) - expect(kinds(state)).toEqual(['done', 'output', 'output', 'step', 'output']) - }) - - it('logs output that arrives with no phase transition to open it', () => { - const state = deriveSandboxState( - [ - rec('sandbox_starting'), - rec('sandbox_output', { phase: 'setup', chunk: 0, lines: ['a'] }), - rec('sandbox_output', { phase: 'setup', chunk: 1, lines: ['b', 'c'] }), - ], - 0, - ) - expect(texts(state)).toEqual(['Starting sandbox…', 'a', 'b', 'c']) - }) - - it('labels phases through the open vocabulary, unknown ones verbatim', () => { - expect( - texts(deriveSandboxState([rec('sandbox_phase', { phase: 'warmup', status: 'started' })], 0)), - ).toEqual(['Warmup…']) - expect( - texts( - deriveSandboxState( - [rec('sandbox_phase', { phase: 'image', step: 'warm_cache', status: 'started' })], - 0, - ), - ), - ).toEqual(['warm_cache…']) - }) - - it('marks a failed phase and keeps its duration', () => { - const state = deriveSandboxState( - [ - rec('sandbox_phase', { phase: 'setup', status: 'started' }), - rec('sandbox_phase', { phase: 'setup', status: 'failed', duration_ms: 4000 }), - ], - 0, - ) - expect(texts(state)).toEqual(['Running setup failed, 4s']) - expect(kinds(state)).toEqual(['failed']) - }) - - it('closes on sandbox_ready with the phase_timings total, not step durations', () => { - const state = deriveSandboxState( - [ - rec('session_scheduled', { source: 'cli' }), - rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('sandbox_starting', { repositories: ['o/r'] }), - rec('sandbox_phase', { phase: 'image', status: 'started' }), rec('sandbox_ready', { - repositories: ['o/r'], cache_tier: 'exact', phase_timings: { image: 1.5, clone: 27.5 }, }), ], 0, ) - expect(state?.done).toBe(true) - expect(state?.sandboxDone).toBe(true) - expect(state?.readySeconds).toBe(29) expect(texts(state)).toEqual([ 'Starting sandbox…', - 'Preparing image…', + 'Preparing image, cached image, 1.2s', 'Sandbox ready, cached image, 29s', ]) - // A phase still open when the box came up is no longer live. - expect(kinds(state)).toEqual(['step', 'done', 'done']) }) - it('starts a fresh log on a wake, dropping the previous start', () => { + it('writes the retry headline comma-separated', () => { const state = deriveSandboxState( [ - rec('session_scheduled', { source: 'cli' }), rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('sandbox_starting'), - rec('sandbox_output', { phase: 'setup', chunk: 0, lines: ['old'] }), - rec('sandbox_ready', {}), - rec('session_idle', {}), - rec('session_starting', { attempt: 0, wake_index: 1 }), - rec('sandbox_starting'), - rec('sandbox_phase', { phase: 'restore', status: 'started' }), - ], - 0, - ) - expect(state?.headline).toBe('Waking the session…') - expect(state?.done).toBe(false) - expect(texts(state)).toEqual(['Starting sandbox…', 'Restoring workspace…']) - - const resumed = deriveSandboxState( - [ - rec('session_starting', { attempt: 0, wake_index: 1 }), - rec('sandbox_starting'), - rec('sandbox_ready', { cache_tier: 'exact' }), - rec('session_resumed', { wake_index: 1 }), - ], - 0, - ) - expect(resumed?.done).toBe(true) - }) - - it('settles on session_idle WITHOUT rewriting the headline', () => { - // The block narrates the START. Folding live status into it made an old - // session's opening line read "Session asleep", which is not what happened - // first — the sleep has its own transcript line further down. - const state = deriveSandboxState( - [ - rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('sandbox_starting'), - rec('sandbox_ready', {}), - rec('session_idle', {}), - ], - 0, - ) - expect(state?.headline).toBe('Starting cloud agent…') - expect(state?.done).toBe(true) - }) - - it('summarizes a settled start in one line, dropping unknown timings', () => { - const timed = deriveSandboxState( - [ - rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('sandbox_starting'), - rec('sandbox_ready', { phase_timings: { image: 12, clone: 30 } }), - ], - 0, - ) - expect(timed?.readySeconds).toBe(42) - expect(sandboxSummary(timed)).toBe('Sandbox ready in 42s') - - // An old feed whose sandbox_ready carried no timings: no invented duration. - const untimed = deriveSandboxState( - [rec('session_starting', { attempt: 0, wake_index: 0 }), rec('sandbox_ready', {})], - 0, - ) - expect(untimed?.readySeconds).toBeNull() - expect(sandboxSummary(untimed)).toBe('Sandbox started') - }) - - it('shows Retrying as the headline and drops the failed start log', () => { - const state = deriveSandboxState( - [ - rec('session_starting', { attempt: 0, wake_index: 0 }), - rec('sandbox_starting'), rec('session_retrying', { reason: 'sandbox provisioning failed', attempt: 1 }), ], 0, ) expect(state?.headline).toBe('Retrying, sandbox provisioning failed') - expect(state?.done).toBe(false) - expect(state?.log).toHaveLength(0) - }) - - it('ignores records at or below the render cursor (--no-records)', () => { - const starting = rec('sandbox_starting') - const ready = rec('sandbox_ready', {}) - expect(deriveSandboxState([starting, ready], ready.feed_seq)).toBeNull() - }) -}) - -describe('lastLines', () => { - const log = Array.from({ length: 25 }, (_, i) => ({ - key: `k${i}`, - kind: 'output' as const, - text: `line ${i}`, - })) - - it('keeps the NEWEST lines — the tail is what you watch during a build', () => { - expect(lastLines(log, 10).map((l) => l.text)).toEqual([ - 'line 15','line 16','line 17','line 18','line 19', - 'line 20','line 21','line 22','line 23','line 24', - ]) - }) - - it('returns everything when the log is shorter than the window', () => { - expect(lastLines(log.slice(0, 3), 10)).toHaveLength(3) - expect(lastLines([], 10)).toEqual([]) - }) -}) - -describe('awaitingAgentPhase', () => { - it('is null with no turn in flight — including the bare interactive session', () => { - expect(awaitingAgentPhase([])).toBeNull() - // A no-prompt `agent` session sits at 'working' status waiting for its - // first message: no turn, no Claude Code process, nothing to narrate. - expect(awaitingAgentPhase([rec('session_starting'), rec('sandbox_ready')])).toBeNull() - }) - - it("reports 'boot' for a fresh execution's first turn (Claude Code starting)", () => { - expect( - awaitingAgentPhase([rec('session_starting'), rec('sandbox_ready'), rec('turn_started')]), - ).toBe('boot') - }) - - it("reports 'turn' through a running turn's lull, even after the harness spoke", () => { - expect( - awaitingAgentPhase([ - rec('session_starting'), - rec('turn_started'), - rec('assistant', {}, 'claude_code'), - ]), - ).toBe('turn') - }) - - it('clears when the turn completes or fails', () => { - const turn = [ - rec('turn_started'), - rec('assistant', {}, 'claude_code'), - rec('turn_completed'), - ] - expect(awaitingAgentPhase(turn)).toBeNull() - expect(awaitingAgentPhase([rec('turn_started'), rec('turn_failed')])).toBeNull() - }) - - it('resets to boot on a wake (a fresh execution boots the harness again)', () => { - expect( - awaitingAgentPhase([ - rec('turn_started'), - rec('assistant', {}, 'claude_code'), - rec('turn_completed'), - rec('session_starting'), - rec('turn_started'), - ]), - ).toBe('boot') - }) -}) - -describe('deliveredUnechoedSends', () => { - const received = (id: string, body: string) => rec('message_received', { message_id: id, body }) - const delivered = (id: string, turn = 't1') => - rec('message_delivered', { message_id: id, turn_id: turn }) - const requeued = (id: string) => rec('message_requeued', { message_id: id }) - const turnFailed = (turn = 't1') => rec('turn_failed', { turn_id: turn, turn_index: 0 }) - const echo = (id: string | null) => ({ - ...rec('user', {}, 'claude_code'), - session_message_id: id, - }) - - it('bridges the gap between delivery and the user-echo record', () => { - expect(deliveredUnechoedSends([received('m1', 'hi'), delivered('m1')])).toEqual([ - { id: 'm1', body: 'hi', cancelled: false }, - ]) - }) - - it('marks a send cancelled when the turn that took it died unanswered', () => { - expect( - deliveredUnechoedSends([received('m1', 'hi'), delivered('m1', 't7'), turnFailed('t7')]), - ).toEqual([{ id: 'm1', body: 'hi', cancelled: true }]) - }) - - it('leaves a send waiting when a DIFFERENT turn failed', () => { - expect( - deliveredUnechoedSends([received('m1', 'hi'), delivered('m1', 't7'), turnFailed('t8')]), - ).toEqual([{ id: 'm1', body: 'hi', cancelled: false }]) - }) - - it('retires the send once its echo record lands', () => { - expect(deliveredUnechoedSends([received('m1', 'hi'), delivered('m1'), echo('m1')])).toEqual([]) - }) - - it('excludes pending (undelivered) and requeued messages', () => { - expect(deliveredUnechoedSends([received('m1', 'hi')])).toEqual([]) - expect( - deliveredUnechoedSends([received('m1', 'hi'), delivered('m1'), requeued('m1')]), - ).toEqual([]) }) - it('keeps delivery order and ignores unrelated echoes', () => { - expect( - deliveredUnechoedSends([ - received('m1', 'first'), - received('m2', 'second'), - delivered('m1'), - delivered('m2'), - echo(null), - ]), - ).toEqual([ - { id: 'm1', body: 'first', cancelled: false }, - { id: 'm2', body: 'second', cancelled: false }, - ]) + it('passes null through — no lifecycle records means no block', () => { + expect(deriveSandboxState([], 0)).toBeNull() }) }) @@ -544,37 +193,19 @@ describe('reshapeTranscript', () => { }) }) -describe('sessionLogText', () => { - const lc = (record_type: string, payload: Record = {}) => - ({ feed_seq: 1, source: 'lifecycle', record_type, payload }) - - it('does not log the FIRST start — the startup block tells that story', () => { - expect(sessionLogText(lc('session_starting', {}))).toBeNull() - expect(sessionLogText(lc('session_starting', { wake_index: 0 }))).toBeNull() - }) - - it('logs a wake, which happens long after the startup block settled', () => { - expect(sessionLogText(lc('session_starting', { wake_index: 2 }))).toBe('Waking the session…') - }) - - it('logs an infra retry distinctly from a wake', () => { - expect(sessionLogText(lc('session_starting', { attempt: 1 }))).toContain('transient error') - expect(sessionLogText(lc('session_retrying', { reason: 'node lost' }))).toBe( +describe('sessionLogText comma wording', () => { + it('writes reasons comma-separated, not middot-separated', () => { + expect(sessionLogText('session_retrying', { reason: 'node lost' })).toBe( 'Retrying, node lost', ) - }) - - it('logs a cancellation with its reason when there is one', () => { - expect(sessionLogText(lc('session_cancelled', {}))).toBe('Session cancelled') - expect(sessionLogText(lc('session_cancelled', { reason: 'budget' }))).toBe( + expect(sessionLogText('session_cancelled', { reason: 'budget' })).toBe( 'Session cancelled, budget', ) }) - it('ignores provisioning chatter', () => { - for (const t of ['sandbox_starting', 'sandbox_phase', 'sandbox_output', 'sandbox_ready', 'turn_started']) { - expect(sessionLogText(lc(t))).toBeNull() - } + it('passes plain lines and nulls through unchanged', () => { + expect(sessionLogText('session_idle', {})).toBe('Session asleep') + expect(sessionLogText('session_starting', { wake_index: 0 })).toBeNull() }) }) @@ -656,42 +287,6 @@ describe('gutterFor', () => { }) }) -describe('humanDuration', () => { - it('scales precision down with size: ms under 1s, one decimal under 5s', () => { - expect(humanDuration(0.428)).toBe('428ms') - expect(humanDuration(1.2)).toBe('1.2s') - expect(humanDuration(4.7)).toBe('4.7s') - expect(humanDuration(3)).toBe('3s') - }) - - it('reads as compact h/m/s components, dropping zero parts', () => { - expect(humanDuration(0)).toBe('0s') - expect(humanDuration(3)).toBe('3s') - expect(humanDuration(62)).toBe('1m 2s') - expect(humanDuration(120)).toBe('2m') - expect(humanDuration(3600)).toBe('1h') - expect(humanDuration(3810)).toBe('1h 3m 30s') - expect(humanDuration(5400)).toBe('1h 30m') - }) - - it('rounds fractional seconds and clamps negatives', () => { - expect(humanDuration(1.2)).toBe('1.2s') - expect(humanDuration(59.7)).toBe('1m') - expect(humanDuration(-5)).toBe('0s') - }) -}) - -describe('hookPhrase', () => { - it('maps known step/phase keys and passes unknown ones through', () => { - expect(hookPhrase('setup')).toBe('Building image') - expect(hookPhrase('image.setup')).toBe('Building image') - expect(hookPhrase('clone')).toBe('Fetching repositories') - expect(hookPhrase('post_clone')).toBe('Post-clone setup') - expect(hookPhrase('post_start')).toBe('Post-start setup') - expect(hookPhrase('custom.step')).toBe('custom.step') - }) -}) - describe('cursorLineUp', () => { it('is null on the first line — the signal to enter transcript navigation', () => { expect(cursorLineUp('', 0)).toBeNull() From 3805459dc0dad85f4d3615a2bdb13a2220a362dc Mon Sep 17 00:00:00 2001 From: hbrooks Date: Wed, 26 Aug 2026 16:23:48 -0400 Subject: [PATCH 3/3] Render the chat from the SDK's shared ChatTurn grouping --- src/lib/chatItems.ts | 138 ++++++++++++++++++++++++++++++++ src/ui/ConnectApp.tsx | 111 +++++++------------------- test/connect-app.test.ts | 164 ++++++++++++++++++++++----------------- 3 files changed, 256 insertions(+), 157 deletions(-) create mode 100644 src/lib/chatItems.ts diff --git a/src/lib/chatItems.ts b/src/lib/chatItems.ts new file mode 100644 index 0000000..40c63f5 --- /dev/null +++ b/src/lib/chatItems.ts @@ -0,0 +1,138 @@ +import { + recordToItems, + type ChatTurn, + type TranscriptItem, +} from '@ellipsis-dev/sdk/store' +import type { SessionRecord } from './types' +import { sessionLogText } from './steps' + +// The chat's transcript items, derived from the SDK's shared ChatTurn shape — +// the SAME grouped turns the dashboard's chat renders (store.chatTurns()): +// tool calls paired with their results, turns closed by their result records, +// a failed turn carrying isError. This file maps those turns onto the +// terminal renderer's TranscriptItem vocabulary; everything downstream +// (folds, layout, rows, the scrollback flush) is unchanged. + +// The sandbox spawn family: the startup block up top narrates these, so the +// chat skips their turns entirely. Logging them here would bury the +// conversation in provisioning noise. +const SANDBOX_RECORD_TYPES = new Set([ + 'sandbox_starting', + 'sandbox_phase', + 'sandbox_output', + 'sandbox_ready', +]) + +// ChatTurns as flat transcript items, in turn order. +// +// Lifecycle turns are reworded through sessionLogText (the SHORT list of +// milestones worth a chat line — asleep, waking, retrying, cancelled), so the +// chat log and the old item path read the same. A wake is ONE line, not two: +// "Waking the session…" settles in place to "Session awake" when the resumed +// record lands, KEEPING ITS KEY, so the scroll anchor and the scrollback +// flush never move. +// +// A turn's closing result record is not itself rendered (its duration and +// cost are bookkeeping; the footer carries the spend), but a failed turn is +// content: turn.isError becomes a red "turn ended with an error" line at the +// turn's end. Pure, for tests. +export function chatTurnsToItems(turns: readonly ChatTurn[]): TranscriptItem[] { + const items: TranscriptItem[] = [] + // Index of the "Waking the session…" line still awaiting its outcome. + let wakeAt = -1 + for (const turn of turns) { + if (turn.role === 'lifecycle') { + for (const node of turn.nodes) { + if (node.kind !== 'lifecycle') continue + if (SANDBOX_RECORD_TYPES.has(node.recordType)) continue + if (node.recordType === 'session_resumed' && wakeAt >= 0) { + items[wakeAt] = { ...items[wakeAt], text: 'Session awake' } + wakeAt = -1 + continue + } + const text = sessionLogText(node.recordType, node.payload ?? {}) + if (!text) continue + items.push({ key: node.key, kind: 'notice', text, spaceBefore: true }) + wakeAt = text === 'Waking the session…' ? items.length - 1 : -1 + } + continue + } + if (turn.role === 'user') { + for (const node of turn.nodes) { + if (node.kind === 'user') { + items.push({ key: node.key, kind: 'user', text: node.text, spaceBefore: true }) + } + } + continue + } + for (const node of turn.nodes) { + if (node.kind === 'assistant') { + items.push({ key: node.key, kind: 'assistant', text: node.text, spaceBefore: true }) + } else if (node.kind === 'thinking') { + items.push({ key: node.key, kind: 'thinking', gutter: '✻', text: node.text, spaceBefore: true }) + } else if (node.kind === 'tool') { + // An orphaned result (its call was never seen — replay can start + // mid-burst) renders as the ⎿ result alone, not under a made-up call. + const orphan = node.name === 'tool' && node.input === null && node.startedAt === null + if (!orphan) { + items.push({ + key: node.key, + kind: 'tool', + gutter: '●', + text: node.name, + detail: node.summary ? `(${node.summary})` : undefined, + spaceBefore: true, + tool: { name: node.name, input: node.input ?? undefined }, + }) + } + // The result rides directly under its call — the pairing is the + // point of the ChatTurn shape. A call still running has none, which + // is what pendingToolCalls keys the live activity line off. + if (node.result !== null) { + items.push({ + key: `${node.key}:r`, + kind: 'tool_result', + gutter: '⎿', + text: node.result || '(no output)', + spaceBefore: false, + isError: node.isError || undefined, + }) + } + } + } + if (turn.isError) { + items.push({ + key: `${turn.key}:err`, + kind: 'summary', + text: 'turn ended with an error', + spaceBefore: true, + isError: true, + }) + } + } + return items +} + +// Agent records that arrived and would render NOTHING — the signal for a +// payload shape this build cannot read (a harness change on the server, an +// out-of-date CLI). Without it such a record is invisible twice over: no row, +// and no hint that a row is missing. Init events are excluded: they are +// deliberately silent. A record the reader THROWS on must cost one count, not +// the whole transcript. Pure, for tests. +export function undisplayedRecordCount( + records: readonly SessionRecord[], + minRenderFeedSeq: number, +): number { + let undisplayed = 0 + for (const r of records) { + if (r.feed_seq <= minRenderFeedSeq || r.source === 'lifecycle') continue + let rendered: TranscriptItem[] + try { + rendered = recordToItems(r, `s${r.feed_seq}`) ?? [] + } catch { + rendered = [] + } + if (rendered.length === 0 && r.record_type !== 'system') undisplayed++ + } + return undisplayed +} diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index bcdf5c0..8963894 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -18,21 +18,20 @@ import { collapseToolRuns, deliveredUnechoedSends, foldCosts, + groupRecordsToChatTurns, humanDuration, lastLines, pendingToolCalls, recordSlice, - recordToItems, sandboxSummary, statusActivityText, - type RecordSlice, type SandboxState, type SessionTranscriptStore, - type TranscriptItem, } from '@ellipsis-dev/sdk/store' -import { deriveSandboxState, sessionLogText } from '../lib/steps' +import { deriveSandboxState } from '../lib/steps' +import { chatTurnsToItems, undisplayedRecordCount } from '../lib/chatItems' import { errorDetail } from '../lib/api' -import type { Ellipsis, SdkRecord, SessionRecord } from '@ellipsis-dev/sdk' +import type { Ellipsis, SdkRecord } from '@ellipsis-dev/sdk' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' @@ -302,22 +301,33 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // lands first (session frame, poll, stream outcome). const closingDown = useRef(false) - // The committed transcript, derived from the store's record log. Keys ride - // feed_seq (the shared per-session order), so items are stable across - // re-derivations. - // Lifecycle records are excluded entirely: the sandbox story renders as the - // one-line progress block up top (sandboxProgress), not as transcript rows. - // Each turn's closing duration/cost summary is dropped too (see - // reshapeTranscript) — the footer carries the session's spend. + // The committed transcript: the SDK's shared ChatTurn grouping (the same + // turns the dashboard's chat renders — tool calls paired with results, + // failed turns marked), mapped onto the terminal's item vocabulary. + // store.chatTurns() memoizes on the records array identity; --no-records + // (minRenderFeedSeq > 0) groups a filtered slice instead, since turns don't + // carry feed_seq. + // The sandbox story is excluded: it renders as the one-line progress block + // up top, not as transcript rows. Each turn's closing duration/cost summary + // is dropped too — the footer carries the session's spend. const { items, undisplayed } = useMemo(() => { - const shaped = reshapeTranscript(recordSlice(snapshot.records), props.minRenderFeedSeq) + const turns = + props.minRenderFeedSeq > 0 + ? groupRecordsToChatTurns( + snapshot.records.filter((r) => r.feed_seq > props.minRenderFeedSeq), + ) + : store.chatTurns() + const items = chatTurnsToItems(turns) // Client-side notes land at the end: they describe what you just did, so // they belong under everything the server has sent so far. for (const note of chatNotes) { - shaped.items.push({ key: note.key, kind: 'notice', text: note.text, spaceBefore: true }) + items.push({ key: note.key, kind: 'notice', text: note.text, spaceBefore: true }) } - return shaped - }, [snapshot.records, props.minRenderFeedSeq, chatNotes]) + return { + items, + undisplayed: undisplayedRecordCount(snapshot.records, props.minRenderFeedSeq), + } + }, [snapshot.records, props.minRenderFeedSeq, chatNotes, store]) // Footer spend: the server's ledger total (the session frame's four cost // columns — the billing authority, resent on every cost tick) with the @@ -1460,75 +1470,6 @@ function fit(text: string, width: number): string { // head above them. const SANDBOX_LOG_ROWS = 10 -// The chat is a LOG of the session: what was said, and what happened to the -// session while it was being said. So the milestones — it went to sleep, it is -// waking again, it was cancelled — land in the transcript, in feed order, -// alongside the conversation (see SESSION_LOG_RECORDS). Without them a session -// that naps between turns leaves an unexplained gap, and the only account of -// the wake is the startup block up top silently rewriting itself. -// -// Each turn's closing `result` summary is dropped: its duration and cost are -// bookkeeping, not conversation — the footer's running spend is where that -// story lives. An error summary survives as its own (red) line under a plain -// label: a failed turn is content. Pure, for tests. -// `undisplayed` counts agent records that arrived and rendered NOTHING — the -// signal for a payload shape this build cannot read (a harness change on the -// server, an out-of-date CLI). Without it such a record is invisible twice -// over: no row, and no hint that a row is missing. Init events are excluded: -// they are deliberately silent here. -export function reshapeTranscript( - records: readonly RecordSlice[], - minRenderFeedSeq: number, -): { items: TranscriptItem[]; undisplayed: number } { - const items: TranscriptItem[] = [] - let undisplayed = 0 - // Index of the "Waking the session…" line still awaiting its outcome, so the - // resumed record can settle it in place instead of adding a second row. The - // line KEEPS ITS KEY, so settling it doesn't move the scroll anchor or the - // ↑/↓ walk. - let wakeAt = -1 - for (const r of records) { - if (r.feed_seq <= minRenderFeedSeq) continue - if (r.source === 'lifecycle') { - if (r.record_type === 'session_resumed' && wakeAt >= 0) { - items[wakeAt] = { ...items[wakeAt], text: 'Session awake' } - wakeAt = -1 - continue - } - const text = sessionLogText(r.record_type, r.payload) - if (text) { - items.push({ key: `s${r.feed_seq}`, kind: 'notice', text, spaceBefore: true }) - wakeAt = text === 'Waking the session…' ? items.length - 1 : -1 - } - continue - } - const isResult = r.source === 'claude_code' && r.payload.kind === 'result' - // recordToItems reads the structural slice plus record_format, which it - // switches on; its typed per-harness param isn't the slice these - // derivations pass, hence the cast. It returns undefined for a - // record_format this SDK build has no reader for — a new harness against an - // old CLI — which counts as undisplayed just like an unreadable payload. - // A record the reader THROWS on (an unrecognized payload kind reaches - // blocksOf(undefined) inside the SDK) must cost one row, not the whole - // transcript: the render is the last place that should die of a wire change. - let rendered: TranscriptItem[] - try { - rendered = recordToItems(r as Parameters[0], `s${r.feed_seq}`) ?? [] - } catch { - rendered = [] - } - if (rendered.length === 0 && r.record_type !== 'system') undisplayed++ - for (const item of rendered) { - if (item.kind === 'summary' && isResult) { - if (item.isError) items.push({ ...item, text: 'turn ended with an error' }) - continue - } - items.push(item) - } - } - return { items, undisplayed } -} - // One screen row. Exactly one terminal line by construction: the text was // pre-fitted to the pane (see transcriptRows), and wrap="truncate" is the // belt-and-braces guarantee — a row that wrapped would push every row below it diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 0248c61..0764c72 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { cursorLineDown, cursorLineUp, reshapeTranscript } from '../src/ui/ConnectApp' +import { groupRecordsToChatTurns } from '@ellipsis-dev/sdk/store' +import { cursorLineDown, cursorLineUp } from '../src/ui/ConnectApp' +import { chatTurnsToItems, undisplayedRecordCount } from '../src/lib/chatItems' import { deriveSandboxState, sessionLogText } from '../src/lib/steps' import { gutterFor, @@ -16,12 +18,15 @@ import { theme } from '../src/lib/theme' import type { TranscriptItem } from '@ellipsis-dev/sdk/store' let seq = 0 -// `record_format` is what recordToItems switches on, so a fixture carries the -// same token the wire does: claude_sdk@1 for agent records, ellipsis_lifecycle@1 -// for platform ones. +// `record_format` is what recordToItems and groupRecordsToChatTurns switch on, +// so a fixture carries the same token the wire does: claude_sdk@1 for agent +// records, ellipsis_lifecycle@1 for platform ones. function rec(recordType: string, payload: Record = {}, source = 'lifecycle') { + const feed = ++seq return { - feed_seq: ++seq, + id: `rec${feed}`, + created_at: '2026-01-01T00:00:00Z', + feed_seq: feed, source, record_type: recordType, record_format: source === 'lifecycle' ? 'ellipsis_lifecycle@1' : 'claude_sdk@1', @@ -78,118 +83,133 @@ describe('deriveSandboxState comma wording', () => { }) }) -describe('reshapeTranscript', () => { +describe('chatTurnsToItems', () => { const assistant = (text: string) => rec('cc', { kind: 'assistant', content: [{ type: 'text', text }] }, 'claude_code') + const user = (text: string) => rec('cc', { kind: 'user', content: text }, 'claude_code') + const toolCall = (id: string, name = 'Bash', input: Record = { command: 'ls' }) => + rec('cc', { kind: 'assistant', content: [{ type: 'tool_use', id, name, input }] }, 'claude_code') + const toolResult = (id: string, text = 'ok') => + rec( + 'cc', + { kind: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: text }] }, + 'claude_code', + ) const result = (over: Record = {}) => rec( 'cc', { kind: 'result', duration_ms: 4000, cost_usd: 0.1, is_error: false, ...over }, 'claude_code', ) + const items = (records: ReturnType[]) => + chatTurnsToItems(groupRecordsToChatTurns(records as never)) - it('drops a turn-closing summary entirely — duration and cost are not conversation', () => { - const { items } = reshapeTranscript([assistant('done!'), result()], 0) - expect(items.map((i) => i.kind)).toEqual(['assistant']) - }) - - it('drops every turn summary across a multi-turn transcript', () => { - const { items } = reshapeTranscript( - [ - assistant('one'), - result({ cost_usd: 0.1 }), - assistant('two'), - result({ cost_usd: 0.25, duration_ms: 2000 }), - ], - 0, - ) - expect(items.map((i) => i.text)).toEqual(['one', 'two']) + it('maps prose, user messages and paired tool calls onto transcript items', () => { + const out = items([user('do it'), toolCall('t1'), toolResult('t1', 'files'), assistant('done')]) + expect(out.map((i) => i.kind)).toEqual(['user', 'tool', 'tool_result', 'assistant']) + expect(out.map((i) => i.text)).toEqual(['do it', 'Bash', 'files', 'done']) }) - it('drops a summary with no assistant message before it', () => { - expect(reshapeTranscript([result()], 0).items).toEqual([]) + it('renders a still-running tool call without a result row', () => { + const out = items([toolCall('t1')]) + expect(out.map((i) => i.kind)).toEqual(['tool']) }) - it('skips records at or below the render cursor (--no-records)', () => { - const hidden = [assistant('old'), result({ cost_usd: 0.1 })] - const cursor = hidden[hidden.length - 1].feed_seq - const { items } = reshapeTranscript( - [...hidden, assistant('new'), result({ cost_usd: 0.18 })], - cursor, - ) - expect(items.map((i) => i.text)).toEqual(['new']) + it('drops a turn-closing summary — duration and cost are not conversation', () => { + expect(items([assistant('one'), result(), assistant('two'), result()]).map((i) => i.text)) + .toEqual(['one', 'two']) + expect(items([result()])).toEqual([]) }) - it('keeps an error summary as its own line under a plain label', () => { - const { items } = reshapeTranscript([assistant('oops'), result({ is_error: true })], 0) - expect(items.map((i) => i.kind)).toEqual(['assistant', 'summary']) - expect(items[1].text).toBe('turn ended with an error') - expect(items[1].isError).toBe(true) + it('keeps a failed turn as its own red line under a plain label', () => { + const out = items([assistant('oops'), result({ is_error: true })]) + expect(out.map((i) => i.kind)).toEqual(['assistant', 'summary']) + expect(out[1].text).toBe('turn ended with an error') + expect(out[1].isError).toBe(true) }) it('settles the waking line in place instead of logging the wake twice', () => { + // The result record closes the turn before the session parks, as on the + // wire — an unclosed turn would keep collecting the post-wake response. const records = [ assistant('done for now'), + result(), rec('session_idle'), rec('session_starting', { wake_index: 1 }), ] - const waking = reshapeTranscript(records, 0) - expect(waking.items.map((i) => i.text)).toEqual([ + const waking = items(records) + expect(waking.map((i) => i.text)).toEqual([ 'done for now', 'Session asleep', 'Waking the session…', ]) - const awake = reshapeTranscript([...records, rec('session_resumed'), assistant('back')], 0) - expect(awake.items.map((i) => i.text)).toEqual([ + const awake = items([...records, rec('session_resumed'), assistant('back')]) + expect(awake.map((i) => i.text)).toEqual([ 'done for now', 'Session asleep', 'Session awake', 'back', ]) - // Same key, so settling the line can't slide the scroll anchor. - expect(awake.items[2].key).toBe(waking.items[2].key) + // Same key, so settling the line can't slide the scroll anchor or + // re-print the flushed row. + expect(awake[2].key).toBe(waking[2].key) }) it('leaves startup detail out of the chat — that story is the startup block', () => { - const { items } = reshapeTranscript( - [ - rec('sandbox_starting'), - rec('sandbox_phase', { phase: 'setup', status: 'started' }), - rec('sandbox_output', { lines: ['installing…'] }), - rec('sandbox_ready', { cache_tier: 'exact' }), - rec('turn_started'), - assistant('hello'), - ], - 0, - ) - expect(items.map((i) => i.text)).toEqual(['hello']) + const out = items([ + rec('sandbox_starting'), + rec('sandbox_phase', { phase: 'setup', status: 'started' }), + rec('sandbox_output', { lines: ['installing…'] }), + rec('sandbox_ready', { cache_tier: 'exact' }), + assistant('hello'), + ]) + expect(out.map((i) => i.text)).toEqual(['hello']) + }) + + it('writes lifecycle reasons comma-separated through the wrapper', () => { + const out = items([rec('session_retrying', { reason: 'node lost' })]) + expect(out.map((i) => i.text)).toEqual(['Retrying, node lost']) }) +}) + +describe('undisplayedRecordCount', () => { + const assistant = (text: string) => + rec('cc', { kind: 'assistant', content: [{ type: 'text', text }] }, 'claude_code') + const count = (records: ReturnType[], min = 0) => + undisplayedRecordCount(records as never, min) it('counts agent records that render nothing, so a shape it cannot read is visible', () => { // A payload this build's reader returns nothing for. const shapeless = rec('assistant', { kind: 'assistant', content: [] }, 'claude_code') - expect(reshapeTranscript([shapeless], 0)).toEqual({ items: [], undisplayed: 1 }) - expect(reshapeTranscript([assistant('fine'), shapeless], 0).undisplayed).toBe(1) - // A harness this build has no reader for at all: recordToItems returns - // undefined for an unknown record_format, which must count, not throw. + expect(count([shapeless])).toBe(1) + expect(count([assistant('fine'), shapeless])).toBe(1) + // A harness this build has no reader for at all: recordToItems renders + // nothing for an unknown record_format, which must count, not throw. const future = { ...rec('assistant', { kind: 'assistant' }, 'grok'), record_format: 'grok_native@1', } - expect(reshapeTranscript([future], 0)).toEqual({ items: [], undisplayed: 1 }) - // A payload the reader THROWS on (an unknown kind reaches blocksOf) costs - // one row, never the whole transcript. + expect(count([future])).toBe(1) + // A payload the reader THROWS on costs one count, never the whole + // transcript. const hostile = rec('assistant', { kind: 'video' }, 'claude_code') - expect(reshapeTranscript([assistant('before'), hostile], 0)).toEqual({ - items: [expect.objectContaining({ text: 'before' })], - undisplayed: 1, - }) - // A readable transcript never warns, and a silent-by-design init doesn't count. - expect(reshapeTranscript([assistant('fine'), result()], 0).undisplayed).toBe(0) - expect( - reshapeTranscript([rec('system', { type: 'system', subtype: 'init' }, 'claude_code')], 0) - .undisplayed, - ).toBe(0) + expect(count([assistant('before'), hostile])).toBe(1) + }) + + it('never warns on a readable transcript, silent-by-design records included', () => { + const result = rec( + 'cc', + { kind: 'result', duration_ms: 4000, cost_usd: 0.1, is_error: false }, + 'claude_code', + ) + expect(count([assistant('fine'), result])).toBe(0) + expect(count([rec('system', { type: 'system', subtype: 'init' }, 'claude_code')])).toBe(0) + expect(count([rec('session_idle')])).toBe(0) + }) + + it('skips records at or below the render cursor (--no-records)', () => { + const shapeless = rec('assistant', { kind: 'assistant', content: [] }, 'claude_code') + expect(count([shapeless], shapeless.feed_seq)).toBe(0) }) })