From ac7640a86c79e42291133536a1e44f34a4e69adc Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 4 Sep 2026 13:29:13 -0400 Subject: [PATCH 1/3] Attach images to a session start and to connect messages --- README.md | 1 + package.json | 2 +- src/commands/session.tsx | 14 +++++++++ src/lib/images.ts | 63 ++++++++++++++++++++++++++++++++++++++++ src/lib/types.ts | 1 + src/ui/ConnectApp.tsx | 53 ++++++++++++++++++++++++++++----- src/ui/commands.ts | 28 +++++++++++++++--- test/commands.test.ts | 15 ++++++++++ test/images.test.ts | 62 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 src/lib/images.ts create mode 100644 test/images.test.ts diff --git a/README.md b/README.md index 4bb801b..53ec784 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ agent session start -e backend "..." # run a prompt in a saved environment agent session start --config-file f.json # ...or from an inline config agent session start --template ellipsis-helper # ...or from a maintained template agent session start --budget 5 "..." # cap this session's spend, in dollars +agent session start --image shot.png "..." # the agent sees the picture on its first turn agent session start --watch "..." # start and immediately stream it agent session list --limit 20 # list recent sessions (filter by --source, --author, --since, …) agent session get # inspect one session (prints a dashboard link) diff --git a/package.json b/package.json index a6a0075..905e577 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test:watch": "vitest" }, "dependencies": { - "@ellipsis-dev/sdk": "^0.25.0", + "@ellipsis-dev/sdk": "^0.27.0", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^12.1.0", diff --git a/src/commands/session.tsx b/src/commands/session.tsx index d699774..51ba0d8 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -47,6 +47,7 @@ import { repoFromCwd } from '../lib/git' import { openBrowser } from '../lib/auth' import { registerConnect, runConnect } from './connect' import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from '../ui/launch' +import { readImageAttachment } from '../lib/images' import { formatStepLine, oneLine, recordText } from '../lib/steps' import { parseRepo, @@ -133,6 +134,12 @@ export function registerSession(program: Command): void { '-p, --prompt ', "the session prompt, appended to the agent's initial user query (or pass it positionally)", ) + .option( + '--image ', + 'attach an image (PNG, JPEG, GIF, or WebP, up to 5 MiB) to the prompt; the agent sees it on its first turn (repeatable)', + collect, + [] as string[], + ) .option( '-m, --metadata ', 'attach metadata (repeatable)', @@ -171,6 +178,7 @@ export function registerSession(program: Command): void { rebuild?: boolean budget?: number prompt?: string + image: string[] metadata: Record detach?: boolean watch?: boolean @@ -249,6 +257,12 @@ export function registerSession(program: Command): void { // Appended to the initial user query at build time; gives this // session instructions on top of the config's shared system prompt. if (promptText) req.prompt = promptText + // Pictures ride the first message inline, the way a paste into a + // local `claude` does: the prompt gains an `[Image #N]` placeholder + // per file and the model sees each as a content block on turn 0. + if (opts.image.length > 0) { + req.images = opts.image.map((path) => readImageAttachment(path).attachment) + } // Run settings ride top-level: --rebuild skips the image cache for // the initial provision (wakes cache as usual; the fresh build's // snapshot refreshes the cache). diff --git a/src/lib/images.ts b/src/lib/images.ts new file mode 100644 index 0000000..5e5760b --- /dev/null +++ b/src/lib/images.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' +import type { ImageAttachment } from './types' + +// Images on a message — the Claude Code paste model. `agent session start +// --image shot.png` and the connect composer's `/image shot.png` read the +// file here, and it rides the request inline (base64) as `images`; the server +// appends one `[Image #N]` placeholder per image to the message body and the +// model sees the picture as a content block on the turn that message opens. +// +// Client-side mirrors of the server limits (session_message_image.py), so a +// wrong file fails fast with a clear message instead of a base64-inflated +// round trip to a 400. The server re-validates; these are UX, not enforcement. +export const MAX_IMAGE_BYTES = 5 * 1024 * 1024 +export const MAX_IMAGES_PER_MESSAGE = 10 + +type ImageMediaType = ImageAttachment['media_type'] + +// What the bytes prove themselves to be; the extension is never consulted. +export function sniffImageType(bytes: Buffer): ImageMediaType | null { + if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) + return 'image/png' + if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return 'image/jpeg' + const head6 = bytes.subarray(0, 6).toString('ascii') + if (head6 === 'GIF87a' || head6 === 'GIF89a') return 'image/gif' + if ( + bytes.length >= 12 && + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ) + return 'image/webp' + return null +} + +// One attachment from a file's bytes, throwing the fast client-side errors +// (empty, oversized, not an image we take). Exported for tests. +export function buildImageAttachment(path: string, bytes: Buffer): ImageAttachment { + if (bytes.length === 0) throw new Error(`${path} is empty`) + if (bytes.length > MAX_IMAGE_BYTES) { + throw new Error( + `${path} is ${(bytes.length / (1024 * 1024)).toFixed(1)} MiB; the limit is 5 MiB per image`, + ) + } + const media_type = sniffImageType(bytes) + if (media_type === null) { + throw new Error(`${path} is not a PNG, JPEG, GIF, or WebP image`) + } + return { media_type, data: bytes.toString('base64') } +} + +export type StagedImage = { name: string; attachment: ImageAttachment } + +export function readImageAttachment(path: string): StagedImage { + return { name: basename(path), attachment: buildImageAttachment(path, readFileSync(path)) } +} + +// The `[Image #N]` placeholders the server appends to the stored body, mirrored +// here so a local echo reads exactly like the row that replaces it. +export function withImagePlaceholders(text: string, count: number): string { + if (count === 0) return text + const placeholders = Array.from({ length: count }, (_, i) => `[Image #${i + 1}]`).join(' ') + return text ? `${text}\n\n${placeholders}` : placeholders +} diff --git a/src/lib/types.ts b/src/lib/types.ts index ddc0bf5..c4b4d8e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -35,6 +35,7 @@ export type ListAgentSessionsResponse = S['SessionsListResponse'] export type StartAgentSessionRequest = NonNullable[0]> export type SessionResponse = S['SessionResponse'] export type SendSessionMessageRequest = S['SendSessionMessageRequest'] +export type ImageAttachment = S['ImageAttachment'] export type SessionLogSegment = S['SessionLogSegment'] export type GetSessionLogResponse = S['GetSessionLogResponse'] diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 96c6226..720151e 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -35,17 +35,20 @@ import type { Ellipsis, SdkRecord } from '@ellipsis-dev/sdk' import { hyperlink } from '../lib/urls' import { usdNumberFromMillicents } from '../lib/output' import { applyEditShortcut } from '../lib/editing' +import { readImageAttachment, withImagePlaceholders, type StagedImage } from '../lib/images' import { CTRL_C_QUIT_HINT, useCtrlCQuit } from './ctrlC' import { fitLines, visibleWidth } from '../lib/markdown' import { SELECTION_GLYPH } from '../lib/sessions' import { inputSurface, theme } from '../lib/theme' import { + commandArgument, completedText, isCommandInput, matchCommands, resolveCommand, type SlashCommand, } from './commands' +import { MAX_IMAGES_PER_MESSAGE } from '../lib/images' import { VERSION } from '../lib/constants' import { activityRows, @@ -286,6 +289,10 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // and once the agent consumes the message its echo record lands as the // real (full-colour) transcript row. const [queued, setQueued] = useState([]) + // Images attached with /image, waiting for the next send — the Claude Code + // paste model, minus the paste (a terminal cannot carry image bytes, so the + // file path stands in). Sent inline on that message and cleared with it. + const [staged, setStaged] = useState([]) // Whether the sandbox ever reached a connectable state, so a terminal status // *before* that (a preflight/budget gate) is reported as a failure, not idle. @@ -595,7 +602,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { (raw: string): void => { const text = raw.trim() setComposer({ text: '', cursor: 0 }) - if (!text) return + // An empty line sends nothing — unless pictures are waiting, in which + // case the message is the pictures (an image-only paste). + if (!text && staged.length === 0) return setUndisplayedSeen(undisplayed) // A leading slash claims the line for the CLI. An unknown one is REFUSED, // not forwarded: a typo'd command sent on as prose is a message you did @@ -615,6 +624,28 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { else setNotice('no other sessions here, this is a single-session connect') return } + if (command.id === 'image') { + const path = commandArgument(text) + if (!path) { + setNotice('usage: /image — attaches the file to your next message') + return + } + if (staged.length >= MAX_IMAGES_PER_MESSAGE) { + setNotice(`✗ at most ${MAX_IMAGES_PER_MESSAGE} images per message`) + return + } + try { + const image = readImageAttachment(path) + const next = [...staged, image] + setStaged(next) + setNotice( + `${next.map((s, i) => `[Image #${i + 1}] ${s.name}`).join(', ')} — sends with your next message`, + ) + } catch (err) { + setNotice(`✗ ${err instanceof Error ? err.message : String(err)}`) + } + return + } } // /stop is the one command that talks to the server, so it rides the async // path below with the sends. @@ -636,14 +667,22 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // Show the message as queued, then post it. The POST returns the // created SessionMessage (protocol v2 §4.2): stamp the chip with its // id so the first messages frame / user-echo record carrying that id - // retires it in favour of the server's own row. - setQueued((prev) => [...prev, { text, messageId: null }]) + // retires it in favour of the server's own row. The chip reads as + // the server stores it — the text, then one `[Image #N]` placeholder + // per attached image — so it reconciles with that row by text. + const images = staged.map((s) => s.attachment) + const shown = withImagePlaceholders(text, images.length) + setStaged([]) + setQueued((prev) => [...prev, { text: shown, messageId: null }]) setNotice(null) - const { message: created } = await api.sessions.sendMessage(sessionId, { message: text }) + const { message: created } = await api.sessions.sendMessage(sessionId, { + message: text, + images, + }) setQueued((prev) => { let stamped = false return prev.map((q) => { - if (!stamped && q.text === text && q.messageId === null) { + if (!stamped && q.text === shown && q.messageId === null) { stamped = true return { text: q.text, messageId: created.id } } @@ -654,14 +693,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { pump() } catch (err) { setQueued((prev) => { - const j = prev.findIndex((q) => q.text === text && q.messageId === null) + const j = prev.findIndex((q) => q.messageId === null) return j < 0 ? prev : [...prev.slice(0, j), ...prev.slice(j + 1)] }) setNotice(`✗ ${errorDetail(err)}`) } })() }, - [api, exit, pump, sessionId, undisplayed, props.onFocusNav], + [api, exit, pump, sessionId, staged, undisplayed, props.onFocusNav], ) // The composer renders whenever sending is possible. diff --git a/src/ui/commands.ts b/src/ui/commands.ts index 6c457ae..6eb12bc 100644 --- a/src/ui/commands.ts +++ b/src/ui/commands.ts @@ -6,7 +6,7 @@ // forwarded. Silently sending "/stpo" to the agent as prose is the failure mode // that rule exists to prevent. -export type CommandId = 'stop' | 'sessions' | 'exit' +export type CommandId = 'stop' | 'image' | 'sessions' | 'exit' export type SlashCommand = { id: CommandId @@ -16,12 +16,21 @@ export type SlashCommand = { aliases?: readonly string[] // The one-line description in the menu, lowercase and imperative. detail: string + // Whether the rest of the line after the name is the command's argument + // (`/image shot.png`). Without it, anything after the name is a typo. + takesArgument?: boolean } // Every command, in menu order: the one that acts on the session first, then the // screen, then the way out. export const SLASH_COMMANDS: readonly SlashCommand[] = [ { id: 'stop', name: 'stop', detail: 'interrupt the agent, keeping the conversation' }, + { + id: 'image', + name: 'image', + detail: 'attach an image file to your next message: /image ', + takesArgument: true, + }, { id: 'sessions', name: 'sessions', detail: 'switch sessions (esc)' }, { id: 'exit', name: 'exit', aliases: ['quit'], detail: 'leave the CLI; the session keeps running' }, ] @@ -58,10 +67,21 @@ export function matchCommands(text: string): SlashCommand[] { // wrong command is worse than being told to finish typing. Pure, for tests. export function resolveCommand(text: string): SlashCommand | null { if (!isCommandInput(text)) return null - const typed = text.slice(1).trim().toLowerCase() - return ( + const trimmed = text.slice(1).trim() + const [word, ...rest] = trimmed.split(/\s+/) + const typed = (word ?? '').toLowerCase() + const command = SLASH_COMMANDS.find((c) => [c.name, ...(c.aliases ?? [])].includes(typed)) ?? null - ) + if (command === null) return null + // Trailing words are only meaningful to a command that takes an argument. + if (rest.length > 0 && !command.takesArgument) return null + return command +} + +// The argument after a command's name (`/image shot.png` → `shot.png`), or +// '' when there is none. Pure, for tests. +export function commandArgument(text: string): string { + return text.slice(1).trim().replace(/^\S+\s*/, '') } // The text the composer holds after tab/enter completes `highlighted`: the whole diff --git a/test/commands.test.ts b/test/commands.test.ts index 23710e6..a063b18 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -5,6 +5,7 @@ import { matchCommands, resolveCommand, SLASH_COMMANDS, + commandArgument, } from '../src/ui/commands' describe('isCommandInput', () => { @@ -101,3 +102,17 @@ describe('the command list itself', () => { } }) }) + +describe('commands with an argument', () => { + it('resolves /image with its path, and refuses trailing words elsewhere', () => { + expect(resolveCommand('/image shot.png')?.id).toBe('image') + expect(resolveCommand('/image')?.id).toBe('image') + expect(resolveCommand('/stop now')).toBeNull() + }) + + it('returns the argument after the name', () => { + expect(commandArgument('/image ~/Desktop/shot.png')).toBe('~/Desktop/shot.png') + expect(commandArgument('/image')).toBe('') + expect(commandArgument('/image a b.png')).toBe('a b.png') + }) +}) diff --git a/test/images.test.ts b/test/images.test.ts new file mode 100644 index 0000000..b355d99 --- /dev/null +++ b/test/images.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_IMAGE_BYTES, + buildImageAttachment, + sniffImageType, + withImagePlaceholders, +} from '../src/lib/images' + +const PNG = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(8, 1), +]) +const JPEG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(8, 1)]) +const GIF = Buffer.concat([Buffer.from('GIF89a', 'ascii'), Buffer.alloc(8, 1)]) +const WEBP = Buffer.concat([ + Buffer.from('RIFF', 'ascii'), + Buffer.alloc(4, 0), + Buffer.from('WEBPVP8 ', 'ascii'), + Buffer.alloc(8, 1), +]) + +describe('sniffImageType', () => { + it('names each supported format from its bytes', () => { + expect(sniffImageType(PNG)).toBe('image/png') + expect(sniffImageType(JPEG)).toBe('image/jpeg') + expect(sniffImageType(GIF)).toBe('image/gif') + expect(sniffImageType(WEBP)).toBe('image/webp') + }) + + it('refuses a RIFF that is not WebP, and anything else', () => { + const wav = Buffer.concat([Buffer.from('RIFF', 'ascii'), Buffer.alloc(4), Buffer.from('WAVE', 'ascii')]) + expect(sniffImageType(wav)).toBeNull() + expect(sniffImageType(Buffer.from('%PDF-1.4', 'ascii'))).toBeNull() + }) +}) + +describe('buildImageAttachment', () => { + it('builds the inline request shape from a valid image', () => { + expect(buildImageAttachment('shot.png', PNG)).toEqual({ + media_type: 'image/png', + data: PNG.toString('base64'), + }) + }) + + it('rejects empty, oversized, and non-image files with the path named', () => { + expect(() => buildImageAttachment('empty.png', Buffer.alloc(0))).toThrow(/empty\.png is empty/) + expect(() => buildImageAttachment('big.png', Buffer.alloc(MAX_IMAGE_BYTES + 1, 1))).toThrow( + /limit is 5 MiB/, + ) + expect(() => buildImageAttachment('notes.txt', Buffer.from('hello'))).toThrow( + /notes\.txt is not a PNG, JPEG, GIF, or WebP/, + ) + }) +}) + +describe('withImagePlaceholders', () => { + it('appends one [Image #N] per image, the way the server stores the body', () => { + expect(withImagePlaceholders('fix it', 0)).toBe('fix it') + expect(withImagePlaceholders('fix it', 2)).toBe('fix it\n\n[Image #1] [Image #2]') + expect(withImagePlaceholders('', 1)).toBe('[Image #1]') + }) +}) From 176b4e4211367882ec0f3a7f9be67310ebf0750c Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 4 Sep 2026 13:46:57 -0400 Subject: [PATCH 2/3] Lock @ellipsis-dev/sdk 0.27.0 --- bun.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index 3e1f1d4..dad5add 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@ellipsis/cli", "dependencies": { - "@ellipsis-dev/sdk": "^0.25.0", + "@ellipsis-dev/sdk": "^0.27.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.25.0", "", {}, "sha512-jA+YATTs78cdVQWpdKo1XbNvlkedb+9g3rsC03qozoT7vvsjwxYM+fce7xS8dGZQ5FzZnvp9fmLeiUNJA/ebow=="], + "@ellipsis-dev/sdk": ["@ellipsis-dev/sdk@0.27.0", "", {}, "sha512-6E7BUmePFQ98djwhw33nfLIcInumcjAknxY3uMbi7AUinlF3Z2f+yeAKyZStwffAB3QTWWJLipBJNqWpX99Dmg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], From 96d64e15f0af0643d47ff2930d95bbe86ee3041f Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 4 Sep 2026 13:48:34 -0400 Subject: [PATCH 3/3] Follow SDK 0.27: flat tool layout, current wake and sandbox wording --- src/ui/ConnectApp.tsx | 12 +++------ src/ui/transcriptRows.ts | 1 - test/connect-app.test.ts | 58 ++++++---------------------------------- 3 files changed, 12 insertions(+), 59 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 720151e..64acf1d 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -56,8 +56,6 @@ import { BRANCH_TEXT_PAD, contentWidth, GUTTER_COLS, - isAgentSpeech, - isToolActivity, itemRows, layOutItems, LIVE_GLYPH, @@ -844,18 +842,16 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { pendingTools.length === 1 ? `Running ${pendingTools[0].text}${pendingTools[0].detail ?? ''}…` : `Running ${pendingTools.length} tool calls (${[...new Set(pendingTools.map((t) => t.text))].join(', ')})…` - // A running tool call nests under the message that made it, in the same - // place its ⎿ result will land — so it takes the SAME predicate - // layOutItems uses for that result. Any disagreement here shows up as the - // live line sitting flat and then jumping a level when the result lands. - const said = visible.filter((i) => !isToolActivity(i)).pop() + // A running tool call sits flat, exactly where its ⎿ result will land: + // every row stands on its own (layOutItems), so the live line never + // jumps a level when the result arrives. return { text: '', label, tick: 'tool' as const, suffix: '', hug, - nested: said != null && isAgentSpeech(said), + nested: false, } } if (working && !infraActivity && (awaitingAgent !== null || sendPending)) { diff --git a/src/ui/transcriptRows.ts b/src/ui/transcriptRows.ts index a9e8e21..def43b6 100644 --- a/src/ui/transcriptRows.ts +++ b/src/ui/transcriptRows.ts @@ -17,7 +17,6 @@ import { export { BRANCH_GLYPH, gutterFor, - isAgentSpeech, isToolActivity, layOutItems, LIVE_GLYPH, diff --git a/test/connect-app.test.ts b/test/connect-app.test.ts index 020fb03..efb0d5f 100644 --- a/test/connect-app.test.ts +++ b/test/connect-app.test.ts @@ -61,7 +61,6 @@ describe('deriveSandboxState comma wording', () => { 0, ) expect(texts(state)).toEqual([ - 'Starting sandbox…', 'Preparing image, cached image, 1.2s', 'Sandbox ready, cached image, 29s', ]) @@ -141,7 +140,7 @@ describe('chatTurnsToItems', () => { expect(waking.map((i) => i.text)).toEqual([ 'done for now', 'Session asleep', - 'Waking the session…', + 'Session waking', ]) const awake = items([...records, rec('session_resumed'), assistant('back')]) expect(awake.map((i) => i.text)).toEqual([ @@ -236,55 +235,14 @@ describe('layOutItems', () => { const res = (key: string): TranscriptItem => ({ key, kind: 'tool_result', text: 'ok' }) const fold = (key: string): TranscriptItem => ({ key: `grp:${key}`, kind: 'notice', text: 'Ran 2' }) - it('nests a call and its result under the message that made them', () => { - const out = layOutItems([prose('a'), call('t1'), res('r1')]) - expect(out.map((p) => [p.item.key, p.nested])).toEqual([ - ['a', false], - ['t1', true], - ['r1', true], - ]) - }) - - it('attaches nested lines, so no blank row detaches them from the parent', () => { - const out = layOutItems([prose('a'), call('t1'), res('r1')]) - expect(out.map((p) => p.attach)).toEqual([false, true, true]) - }) - - it('nests a collapsed fold too — it stands in for the run', () => { - const out = layOutItems([prose('a'), fold('t1')]) - expect(out[1]).toMatchObject({ nested: true }) - }) - - it('leaves a turn-opening tool call flat, never branching off YOUR message', () => { - // Your message is a lifted box; a ⎿ branch under it would read as work you - // did rather than work the agent did. - const out = layOutItems([user('u'), call('t1'), res('r1')]) - expect(out.map((p) => [p.item.key, p.nested])).toEqual([ - ['u', false], - ['t1', false], - ['r1', false], - ]) - }) - - it('nests under a ✻ thinking block too — thinking is the agent speaking', () => { - // With extended thinking on, thinking → tool_use → tool_result is the usual - // turn shape, so treating thinking as not-the-agent would flatten almost - // every run in the transcript. + it('lays every row out flat, tool runs included — the SDK contract since 0.27', () => { + // A run is never indented under the message before it: an indented run + // under a message that has scrolled by reads as if the message were the + // subject, when the run is the agent's own next step in the turn. const think: TranscriptItem = { key: 'th', kind: 'thinking', text: 'hmm', gutter: '✻' } - const out = layOutItems([think, fold('t1')]) - expect(out[1]).toMatchObject({ nested: true, attach: true }) - }) - - it('leaves a run with no parent above it flat', () => { - // Replayed history can start mid-burst; there is nothing to hang off. - const out = layOutItems([call('t1'), res('r1'), prose('a')]) - expect(out.map((p) => p.nested)).toEqual([false, false, false]) - }) - - it('keeps prose, user messages and notices flat', () => { - const notice: TranscriptItem = { key: 'n', kind: 'notice', text: 'Session asleep' } - const out = layOutItems([prose('a'), user('u'), notice]) - expect(out.every((p) => !p.nested)).toBe(true) + const out = layOutItems([prose('a'), call('t1'), res('r1'), think, fold('t2'), user('u')]) + expect(out.map((p) => p.item.key)).toEqual(['a', 't1', 'r1', 'th', 'grp:t2', 'u']) + expect(out.every((p) => !p.nested && !p.attach)).toBe(true) }) })