feat(tui): add v2 terminal interface

This commit is contained in:
Dax Raad 2026-06-26 14:20:47 -04:00
commit e6f660fecf
73 changed files with 3028 additions and 2035 deletions

View file

@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode-ai/sdk/v2"
import {
connectionSummary,
connectMethods,
credentialConnections,
integrationOptions,
} from "../../../../src/component/dialog-integration"
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
methods: [],
connections: [],
...value,
})
describe("integrationOptions", () => {
test("keeps popular integrations first and sorts the rest alphabetically", () => {
expect(
integrationOptions([
integration({ id: "mistral", name: "Mistral" }),
integration({ id: "openai", name: "OpenAI" }),
integration({ id: "custom-z", name: "Zebra" }),
integration({ id: "anthropic", name: "Anthropic" }),
]).map((item) => item.id),
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
})
})
describe("connectMethods", () => {
test("offers key and OAuth methods but not environment discovery", () => {
expect(
connectMethods(
integration({
id: "example",
name: "Example",
methods: [
{ type: "env", names: ["EXAMPLE_KEY"] },
{ type: "key", label: "API key" },
{ type: "oauth", id: "account", label: "Account" },
],
}),
).map((method) => method.type),
).toEqual(["oauth", "key"])
})
})
describe("credentialConnections", () => {
test("returns removable credential connections only", () => {
expect(
credentialConnections(
integration({
id: "example",
name: "Example",
connections: [
{ type: "env", name: "EXAMPLE_KEY" },
{ type: "credential", id: "cred_1", label: "Work" },
],
}),
),
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
})
})
describe("connectionSummary", () => {
test("shows credential labels and environment variables", () => {
expect(
connectionSummary(
integration({
id: "example",
name: "Example",
connections: [
{ type: "credential", id: "cred_1", label: "Work" },
{ type: "env", name: "EXAMPLE_KEY" },
],
}),
),
).toBe("Work, $EXAMPLE_KEY")
})
})

View file

@ -3,7 +3,6 @@
exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read rows at a narrow width 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
@ -14,12 +13,10 @@ exports[`TUI inline tool wrapping snapshots consecutive grep, glob, and read row
exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool text 1`] = `
" ✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
No LSP server available for this file type.
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
@ -33,7 +30,6 @@ exports[`TUI inline tool wrapping keeps separation after a shell output block 1`
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
@ -48,7 +44,6 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1
✱ Grep "OPENCODE.*DB|database|sqlite|drizzle|dev.*db|data.
*dir|xdg|APPDATA" in packages/opencode/src (151 matches)
✱ Glob "**/*db*" in packages/opencode (6 matches)
→ Read packages/opencode/src/storage/db.ts [offset=1, limit=130]
→ Read packages/opencode/src/index.ts [offset=1, limit=100]
@ -76,7 +71,7 @@ exports[`TUI inline tool wrapping separates a task row from a preceding inline d
`;
exports[`TUI inline tool wrapping separates an inline row from the previous assistant summary 1`] = `
" Build · Little Frank · 53.1s
" Build · Little Frank · 53.1s
✓ Build Task — Review changes
↳ 48 toolcalls · 1m 40s"

View file

@ -44,6 +44,14 @@ test("refreshes resources into reactive getters", async () => {
location: { directory },
},
})
if (url.pathname === "/api/session/ses_test/message")
return json({
data: [
{ id: "msg_second", type: "user", text: "Second", time: { created: 2 } },
{ id: "msg_first", type: "user", text: "First", time: { created: 1 } },
],
cursor: {},
})
if (url.pathname === "/api/agent")
return json({
location,
@ -60,7 +68,7 @@ test("refreshes resources into reactive getters", async () => {
function Probe() {
data = useData()
onMount(ready)
return <box />
return <text>{data.session.message.get("ses_test", "msg_second")?.id ?? "missing"}</text>
}
const app = await testRender(() => (
@ -82,9 +90,14 @@ test("refreshes resources into reactive getters", async () => {
expect(data.location.agent.list(location)).toBeUndefined()
await data.session.refresh("ses_test")
await data.session.message.refresh("ses_test")
await data.location.agent.refresh()
expect(data.session.get("ses_test")?.title).toBe("Test session")
expect(data.session.message.ids("ses_test")).toEqual(["msg_first", "msg_second"])
expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second")
await app.renderOnce()
expect(app.captureCharFrame()).toContain("msg_second")
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
} finally {
@ -92,6 +105,74 @@ test("refreshes resources into reactive getters", async () => {
}
})
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventSource()
const requests = { event: 0, model: 0 }
const calls = createFetch((url) => {
if (url.pathname === "/api/event") {
requests.event++
return events.response()
}
if (url.pathname !== "/api/model") return
requests.model++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{
id: `model-${requests.model}`,
providerID: "provider",
name: `Model ${requests.model}`,
api: { type: "native" },
capabilities: { tools: false, input: [], output: [] },
cost: [],
limit: { context: 1, output: 1 },
request: { headers: {}, body: {} },
status: "active",
time: { released: 0 },
variants: [],
},
],
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
events.disconnect()
await wait(() => data.connection.status() === "reconnecting")
expect(data.connection.attempt()).toBe(1)
expect(data.connection.error()).toBe("Event stream disconnected")
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
expect(requests.event).toBe(2)
expect(data.connection.status()).toBe("connected")
expect(data.connection.attempt()).toBe(0)
expect(data.connection.error()).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("refreshes integrations after integration updates", async () => {
const events = createEventSource()
const requests = { integration: 0, model: 0, provider: 0 }
@ -241,6 +322,134 @@ test("refreshes references after updates", async () => {
}
})
test("adds and dismisses permission requests from live events", async () => {
const events = createEventSource()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_permission_asked_1",
type: "permission.v2.asked",
properties: {
id: "per_1",
sessionID: "ses_1",
action: "bash",
resources: ["bun test"],
},
})
emitEvent(events, {
id: "evt_permission_asked_2",
type: "permission.v2.asked",
properties: {
id: "per_2",
sessionID: "ses_1",
action: "read",
resources: [".env"],
},
})
await wait(() => data.session.permission.list("ses_1")?.length === 2)
emitEvent(events, {
id: "evt_permission_replied_1",
type: "permission.v2.replied",
properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
})
await wait(() => data.session.permission.list("ses_1")?.length === 1)
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe("per_2")
emitEvent(events, {
id: "evt_permission_replied_2",
type: "permission.v2.replied",
properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
})
await wait(() => data.session.permission.list("ses_1")?.length === 0)
} finally {
app.renderer.destroy()
}
})
test("adds and dismisses question requests from live events", async () => {
const events = createEventSource()
const calls = createFetch(undefined, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
await wait(() => data.connection.status() === "connected")
emitEvent(events, {
id: "evt_question_asked_1",
type: "question.v2.asked",
properties: {
id: "que_1",
sessionID: "ses_1",
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
},
})
emitEvent(events, {
id: "evt_question_asked_2",
type: "question.v2.asked",
properties: {
id: "que_2",
sessionID: "ses_1",
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
},
})
await wait(() => data.session.question.list("ses_1")?.length === 2)
emitEvent(events, {
id: "evt_question_replied_1",
type: "question.v2.replied",
properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
})
await wait(() => data.session.question.list("ses_1")?.length === 1)
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
emitEvent(events, {
id: "evt_question_rejected_2",
type: "question.v2.rejected",
properties: { sessionID: "ses_1", requestID: "que_2" },
})
await wait(() => data.session.question.list("ses_1")?.length === 0)
} finally {
app.renderer.destroy()
}
})
test("settles pending tools when a live failure arrives", async () => {
const events = createEventSource()
const calls = createFetch(undefined, events)
@ -334,7 +543,7 @@ test("settles pending tools when a live failure arrives", async () => {
})
await wait(() => {
const assistant = sync.session.message.list("session-1")?.[0]
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
return (
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
@ -342,7 +551,7 @@ test("settles pending tools when a live failure arrives", async () => {
)
})
const assistant = sync.session.message.list("session-1")?.[0]
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
@ -360,10 +569,10 @@ test("settles pending tools when a live failure arrives", async () => {
metadata: { fake: { call: true } },
resultMetadata: { fake: { result: true } },
})
expect((sync.session.message.list("session-1") ?? []).map((message) => message.type)).toEqual([
"assistant",
"model-switched",
expect(sync.session.message.list("session-1").map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"assistant",
])
} finally {
app.renderer.destroy()
@ -399,6 +608,8 @@ test("renders admitted prompts only after they become model-visible", async () =
try {
await mounted
const received: string[] = []
const unsubscribe = sync.listen((event) => received.push(event.name))
emitEvent(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
@ -425,10 +636,17 @@ test("renders admitted prompts only after they become model-visible", async () =
})
await wait(() => sync.session.message.list("session-1")?.length === 1)
expect(received.slice(-2)).toEqual(["session.next.prompt.admitted", "session.next.prompted"])
unsubscribe()
const message = sync.session.message.list("session-1")?.[0]
expect(message?.type).toBe("user")
if (message?.type !== "user") return
expect(message).toMatchObject({ id: "msg_user_1", text: "hello" })
expect(sync.session.message.ids("session-1")).toEqual(["msg_user_1"])
expect(sync.session.message.ids("missing")).toEqual([])
expect(sync.session.message.get("session-1", "msg_user_1")).toBe(message)
expect(sync.session.message.get("session-1", "missing")).toBeUndefined()
expect(received).toHaveLength(3)
} finally {
app.renderer.destroy()
}

View file

@ -1,6 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createSignal, For, Show } from "solid-js"
import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
import { For } from "solid-js"
import { testRender, type JSX } from "@opentui/solid"
import {
formatCompletedSubagentDetail,
@ -13,7 +12,6 @@ import {
parseQuestionAnswers,
parseQuestions,
parseTodos,
alwaysSeparate,
toolDisplay,
} from "../../../src/routes/session"
@ -52,40 +50,10 @@ const tools: readonly ToolFixture[] = [
},
] as const
function ShellOutput() {
return (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
marginTop={1}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
gap={1}
>
<box gap={1}>
<text>$ ls</text>
<text>file.ts</text>
</box>
</box>
)
}
function UserMessage() {
return (
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
<box paddingTop={1} paddingBottom={1} paddingLeft={2}>
<text>Check whether the next tool remains separated.</text>
</box>
</box>
)
}
function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) {
function Fixture(props: { errorExpanded?: boolean }) {
return (
<box flexDirection="column" width={72}>
<box flexDirection="column">
{props.before === "shell" && <ShellOutput />}
{props.before === "user" && <UserMessage />}
<For each={tools}>
{(item) => (
<InlineToolRow
@ -105,94 +73,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
)
}
function TaskRowsFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow icon="✱" complete={true} pending="">
Grep "Task" (2 matches)
</InlineToolRow>
<InlineToolRow icon="⠙" complete={true} pending="" separate={true}>
Explore Task Inspect active task spacing
</InlineToolRow>
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</box>
)
}
function LoadedReadBeforeTaskFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
<box paddingLeft={3}>
<text paddingLeft={3}> Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="" separate={true}>
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
</box>
)
}
function AssistantSummaryBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)} paddingLeft={3}>
<text> Build · Little Frank · 53.1s</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
</InlineToolRow>
</box>
)
}
function AssistantErrorBeforeInlineFixture() {
return (
<box flexDirection="column" width={72}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
>
<text>Managed inference requires an active Member plan</text>
</box>
<InlineToolRow icon="✓" complete={true} pending="">
{"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"}
</InlineToolRow>
</box>
)
}
function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: ScrollBoxRenderable) => void }) {
return (
<scrollbox ref={props.scroll} stickyScroll={true} stickyStart="bottom" height={3} width={72}>
<box height={1}>
<text>First row</text>
</box>
<box height={1}>
<text>Second row</text>
</box>
<Show when={props.separated}>
<box ref={(el: BoxRenderable) => alwaysSeparate.add(el)}>
<text>Assistant text</text>
</box>
</Show>
<InlineToolRow icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</scrollbox>
)
}
function FailedPendingToolFixture() {
return (
<InlineToolRow icon="%" complete={false} pending="Preparing patch..." failed={true} failure="Patch failed">
@ -245,10 +125,18 @@ describe("TUI inline tool wrapping", () => {
parseApplyPatchFiles([
null,
{ type: "add" },
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0 },
{ file: "a.ts", patch: "diff", additions: 1, deletions: 0, status: "added" },
]),
).toEqual([
{ type: "add", relativePath: "a.ts", filePath: "a.ts", patch: "diff", deletions: 0, movePath: undefined },
{
type: "add",
relativePath: "a.ts",
filePath: "a.ts",
patch: "diff",
additions: 1,
deletions: 0,
movePath: undefined,
},
])
expect(parseTodos([null, { status: "pending" }, { status: "pending", content: "Safe" }])).toEqual([
{ status: "pending", content: "Safe" },
@ -299,53 +187,4 @@ describe("TUI inline tool wrapping", () => {
expect(await renderFrame(() => <Fixture errorExpanded />, { width: 72, height: 12 })).toMatchSnapshot()
})
test("keeps separation after a shell output block", async () => {
expect(await renderFrame(() => <Fixture before="shell" />, { width: 72, height: 16 })).toMatchSnapshot()
})
test("keeps separation after a padded user message", async () => {
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
})
test("separates after a multi-line task row", async () => {
expect(await renderFrame(() => <TaskRowsFixture />, { width: 72, height: 10 })).toMatchSnapshot()
})
test("separates a task row from a preceding inline detail", async () => {
expect(await renderFrame(() => <LoadedReadBeforeTaskFixture />, { width: 72, height: 8 })).toMatchSnapshot()
})
test("separates an inline row from the previous assistant summary", async () => {
expect(await renderFrame(() => <AssistantSummaryBeforeInlineFixture />, { width: 72, height: 5 })).toMatchSnapshot()
})
test("separates an inline row from the previous assistant error", async () => {
expect(await renderFrame(() => <AssistantErrorBeforeInlineFixture />, { width: 72, height: 7 })).toMatchSnapshot()
})
test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => {
const [separated, setSeparated] = createSignal(false)
let scroll: ScrollBoxRenderable | undefined
testSetup = await testRender(
() => <StickyScrollFixture separated={separated()} scroll={(value) => (scroll = value)} />,
{
width: 72,
height: 3,
},
)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(3)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
setSeparated(true)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(5)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
setSeparated(false)
await testSetup.renderOnce()
expect(scroll?.scrollHeight).toBe(3)
expect(scroll?.scrollTop).toBe(Math.max(0, scroll!.scrollHeight - scroll!.viewport.height))
})
})

View file

@ -0,0 +1,105 @@
import { expect, test } from "bun:test"
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
import { reduceSessionRows } from "../../../src/routes/session/rows"
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessage[] = [
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
assistant("assistant-1", [
{ type: "text", id: "text-1", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
{ type: "tool", id: "glob-1", name: "glob", state: pending(), time: { created: 3 } },
]),
assistant("assistant-2", [
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
{ type: "text", id: "text-2", text: "Done" },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
{ type: "part", ref: { messageID: "assistant-1", partID: "text-1" } },
{
type: "group",
kind: "exploration",
pending: [],
completed: true,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-1", partID: "glob-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
{ type: "part", ref: { messageID: "assistant-2", partID: "text-2" } },
])
})
test("keeps non-exploration tools as individual part rows", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
{ type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "bash-1" } },
{
type: "group",
kind: "exploration",
pending: [],
completed: false,
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
},
])
})
test("groups across empty assistant reasoning parts", () => {
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "reasoning", id: "reasoning-1", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
]),
assistant("assistant-2", [
{ type: "reasoning", id: "reasoning-2", text: "" },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning-1" } },
{
type: "group",
kind: "exploration",
pending: [],
completed: false,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
}
}
function pending() {
return { status: "pending" as const, input: "" }
}