tui/mini: consolidate stream and panel internals (#37903)

This commit is contained in:
Simon Klee
2026-07-20 12:45:48 +02:00
committed by GitHub
parent 0eb71d0fc7
commit 75c7ac6a2c
21 changed files with 965 additions and 1145 deletions
+5 -34
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
import { catalogModel, catalogProvider } from "./fixture/catalog"
afterEach(() => {
mock.restore()
@@ -58,45 +59,15 @@ describe("run catalog shared", () => {
test("merges current providers and models into the footer catalog shape", () => {
const providers = runProviders(
[catalogProvider("openai", "OpenAI")],
[
{
id: "openai",
name: "OpenAI",
package: "",
},
],
[
{
catalogModel({
id: "gpt-5",
modelID: "openai",
providerID: "openai",
name: "Little Frank",
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
variants: [{ id: "high" }],
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
status: "active",
enabled: true,
limit: {
context: 128000,
output: 8192,
},
},
variants: ["high"],
}),
],
)
+2 -20
View File
@@ -2,30 +2,12 @@ import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
return input
}
function toolPart(
name: string,
state: SessionMessageAssistantTool["state"],
id = `${name}-1`,
): SessionMessageAssistantTool {
return {
type: "tool",
id,
name,
state,
time:
state.status === "streaming"
? { created: 1 }
: state.status === "completed" || state.status === "error"
? { created: 1, ran: 1, completed: 2 }
: { created: 1, ran: 1 },
}
}
function toolCommit(input: {
tool: string
state: SessionMessageAssistantTool["state"]
@@ -45,7 +27,7 @@ function toolCommit(input: {
input.toolState ??
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
messageID: input.messageID,
part: toolPart(input.tool, input.state, input.id),
part: canonicalToolPart(input.tool, input.state, input.id),
})
}
+67
View File
@@ -0,0 +1,67 @@
import { spyOn } from "bun:test"
import type {
LocationRef,
ModelListOutput,
OpenCodeClient,
ProviderListOutput,
} from "@opencode-ai/client/promise"
export function catalogProvider(id: string, name: string): ProviderListOutput["data"][number] {
return {
id,
name,
package: "",
}
}
export function catalogModel(input: {
id: string
modelID?: string
providerID: string
name?: string
context?: number
variants?: string[]
}): ModelListOutput["data"][number] {
return {
id: input.id,
modelID: input.modelID ?? input.id,
providerID: input.providerID,
name: input.name ?? input.id,
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
variants: (input.variants ?? []).map((id) => ({ id })),
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: input.context ?? 128_000, output: 8_192 },
}
}
export function stubCatalogLists(
sdk: OpenCodeClient,
input: {
location?: LocationRef
providers?: ProviderListOutput["data"]
models?: ModelListOutput["data"]
} = {},
) {
const location = {
directory: input.location?.directory ?? "/tmp",
workspaceID: input.location?.workspaceID,
project: { id: "proj_1", directory: input.location?.directory ?? "/tmp" },
}
const empty = { location, data: [] }
return {
provider: spyOn(sdk.provider, "list").mockResolvedValue({ location, data: input.providers ?? [] } as never),
model: spyOn(sdk.model, "list").mockResolvedValue({ location, data: input.models ?? [] } as never),
agent: spyOn(sdk.agent, "list").mockResolvedValue(empty as never),
reference: spyOn(sdk.reference, "list").mockResolvedValue(empty as never),
command: spyOn(sdk.command, "list").mockResolvedValue(empty as never),
skill: spyOn(sdk.skill, "list").mockResolvedValue(empty as never),
}
}
@@ -0,0 +1,67 @@
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../src/mini/types"
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
const prompts = new Set<(input: RunPrompt) => void>()
const queuedRemoves = new Set<(messageID: string) => boolean | Promise<boolean>>()
const closes = new Set<() => void>()
const events = input.events ?? []
const commits = input.commits ?? []
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => prompts.delete(fn)
},
onQueuedRemove(fn) {
queuedRemoves.add(fn)
return () => queuedRemoves.delete(fn)
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => closes.delete(fn)
},
event(next) {
events.push(next)
calls.push({ type: "event", value: next })
},
append(next) {
commits.push(next)
calls.push({ type: "commit", value: next })
},
idle: () => Promise.resolve(),
close() {
if (closed) return
closed = true
for (const fn of [...closes]) fn()
},
destroy() {
api.close()
prompts.clear()
queuedRemoves.clear()
closes.clear()
},
}
return {
api,
events,
commits,
calls,
submit(text: string, mode?: RunPrompt["mode"]) {
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
for (const fn of [...prompts]) fn(prompt)
},
removeQueued(messageID: string) {
for (const fn of [...queuedRemoves]) void fn(messageID)
},
}
}
@@ -0,0 +1,20 @@
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
export function canonicalToolPart(
name: string,
state: SessionMessageAssistantTool["state"],
id = `${name}-1`,
): SessionMessageAssistantTool {
return {
type: "tool",
id,
name,
state,
time:
state.status === "streaming"
? { created: 1 }
: state.status === "completed" || state.status === "error"
? { created: 1, ran: 1, completed: 2 }
: { created: 1, ran: 1 },
}
}
+21 -3
View File
@@ -422,6 +422,7 @@ test("direct skill panel renders searchable skill list", async () => {
command({ name: "internal", description: "Skill command", source: "skill" }),
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
])
const selected: string[] = []
const app = await testRender(
() => (
@@ -430,7 +431,9 @@ test("direct skill panel renders searchable skill list", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
commands={commands}
onClose={() => {}}
onSelect={() => {}}
onSelect={(name) => {
selected.push(name)
}}
/>
</box>
),
@@ -451,6 +454,11 @@ test("direct skill panel renders searchable skill list", async () => {
expect(frame).toContain("formatter")
expect(frame).toContain("Apply formatter fixes")
expect(frame).not.toContain("review")
await app.mockInput.typeText("format")
await app.renderOnce()
expect(app.captureCharFrame()).not.toContain("internal")
app.mockInput.pressEnter()
expect(selected).toEqual(["formatter"])
} finally {
app.renderer.destroy()
}
@@ -674,6 +682,8 @@ test("direct subagent panel closes when moving up from the first item", async ()
test("direct queued prompt panel renders pending prompt actions", async () => {
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
const edited: string[] = []
const deleted: string[] = []
const app = await testRender(
() => (
@@ -682,8 +692,12 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
theme={() => RUN_THEME_FALLBACK.footer}
prompts={prompts}
onClose={() => {}}
onEdit={() => {}}
onDelete={() => {}}
onEdit={(prompt) => {
edited.push(prompt.messageID)
}}
onDelete={(prompt) => {
deleted.push(prompt.messageID)
}}
/>
</box>
),
@@ -701,6 +715,10 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
expect(frame).not.toContain("┌")
expect(frame).not.toContain("┃")
expectPaletteList(list, 0)
app.mockInput.pressKey("e", { ctrl: true })
app.mockInput.pressKey("DELETE")
expect(edited).toEqual(["m-1"])
expect(deleted).toEqual(["m-1"])
} finally {
app.renderer.destroy()
}
@@ -9,6 +9,7 @@ import {
permissionRun,
} from "../../src/mini/permission.shared"
import type { MiniPermissionRequest } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
return {
@@ -89,18 +90,16 @@ describe("run permission shared", () => {
req({
action: "shell",
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
tool: {
type: "tool",
id: "call-shell",
name: "shell",
state: {
tool: canonicalToolPart(
"shell",
{
status: "running",
input: { command: "git status --short" },
structured: {},
content: [],
},
time: { created: 1, ran: 1 },
},
"call-shell",
),
}),
),
).toMatchObject({
@@ -137,18 +136,16 @@ describe("run permission shared", () => {
action: "websearch",
metadata: { provider: "parallel" },
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
tool: {
type: "tool",
id: "call-search",
name: "websearch",
state: {
tool: canonicalToolPart(
"websearch",
{
status: "running",
input: { query: "current releases" },
structured: { provider: "exa", retained: true },
content: [],
},
time: { created: 1, ran: 1 },
},
"call-search",
),
}),
),
).toMatchObject({
@@ -164,18 +161,16 @@ describe("run permission shared", () => {
action: "edit",
resources: ["src/index.ts"],
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
tool: {
type: "tool",
id: "call-edit",
name: "edit",
state: {
tool: canonicalToolPart(
"edit",
{
status: "running",
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
structured: {},
content: [],
},
time: { created: 1, ran: 1 },
},
"call-edit",
),
}),
),
).toMatchObject({
+10 -63
View File
@@ -2,67 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import type { Resolved } from "../../src/config"
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
import { catalogModel, catalogProvider } from "./fixture/catalog"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
function ok<T>(data: T) {
return Promise.resolve(data)
}
function provider(id: string, name: string) {
return {
id,
name,
api: { type: "native" as const, settings: {} },
request: { headers: {}, body: {} },
}
}
function model(id: string, providerID: string, context: number, variants: string[] = []) {
return {
id,
providerID,
api: {
id: providerID,
type: "native" as const,
settings: {},
},
name: id,
capabilities: {
tools: true,
input: ["text"],
output: ["text"],
},
request: {
headers: {},
body: {},
},
variants: variants.map((variant) => ({
id: variant,
headers: {},
body: {},
})),
time: {
released: 1,
},
cost: [
{
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
},
],
limit: {
context,
output: 8192,
},
status: "active" as const,
enabled: true,
}
}
function config(input?: {
leader?: string
leaderTimeout?: number
@@ -165,10 +107,15 @@ describe("run runtime boot", () => {
test("loads v2 providers and models for model selector data", async () => {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const providers = [provider("openai", "OpenAI")]
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
const location = { directory: "/workspace", project: { id: "proj_1", directory: "/workspace" } }
const providerList = spyOn(sdk.provider, "list").mockResolvedValue({
location,
data: [catalogProvider("openai", "OpenAI")],
} as never)
spyOn(sdk.model, "list").mockResolvedValue({
location,
data: [catalogModel({ id: "gpt-5", providerID: "openai", variants: ["high", "minimal"] })],
} as never)
await expect(resolveModelInfo(sdk, { directory: "/workspace" })).resolves.toEqual({
providers: [
+18 -94
View File
@@ -1,87 +1,11 @@
import { describe, expect, test } from "bun:test"
import { runPromptQueue } from "../../src/mini/runtime.queue"
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../src/mini/types"
function footer() {
const prompts = new Set<(input: RunPrompt) => void>()
const queuedRemoves = new Set<(messageID: string) => void>()
const closes = new Set<() => void>()
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt(fn) {
prompts.add(fn)
return () => {
prompts.delete(fn)
}
},
onQueuedRemove(fn) {
queuedRemoves.add(fn)
return () => {
queuedRemoves.delete(fn)
}
},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(next) {
events.push(next)
},
append(next) {
commits.push(next)
},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
for (const fn of [...closes]) {
fn()
}
},
destroy() {
api.close()
prompts.clear()
closes.clear()
},
}
return {
api,
events,
commits,
submit(text: string, mode?: RunPrompt["mode"]) {
const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
for (const fn of [...prompts]) {
fn(next)
}
},
removeQueued(messageID: string) {
for (const fn of [...queuedRemoves]) fn(messageID)
},
}
}
import type { RunPrompt } from "../../src/mini/types"
import { createFooterApiFixture } from "./fixture/footer-api"
describe("run runtime queue", () => {
test("ignores empty prompts", async () => {
const ui = footer()
const ui = createFooterApiFixture()
let calls = 0
const task = runPromptQueue({
@@ -99,7 +23,7 @@ describe("run runtime queue", () => {
})
test("treats /exit as a close command", async () => {
const ui = footer()
const ui = createFooterApiFixture()
let calls = 0
const task = runPromptQueue({
@@ -116,7 +40,7 @@ describe("run runtime queue", () => {
})
test("treats /new as a local session command", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
let created = 0
@@ -149,7 +73,7 @@ describe("run runtime queue", () => {
})
test("shell mode submits /exit as a shell command", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: RunPrompt[] = []
const task = runPromptQueue({
@@ -168,7 +92,7 @@ describe("run runtime queue", () => {
})
test("shell mode submits /new instead of creating a session", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: RunPrompt[] = []
let created = 0
@@ -192,7 +116,7 @@ describe("run runtime queue", () => {
})
test("shell mode does not append a synthetic user row", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,
@@ -207,7 +131,7 @@ describe("run runtime queue", () => {
})
test("shell mode does not emit a turn duration summary", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,
@@ -223,7 +147,7 @@ describe("run runtime queue", () => {
})
test("preserves whitespace for initial input", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
await runPromptQueue({
@@ -248,7 +172,7 @@ describe("run runtime queue", () => {
})
test("passes prompts to onSend", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
await runPromptQueue({
@@ -266,7 +190,7 @@ describe("run runtime queue", () => {
})
test("appends the user row before the turn starts", async () => {
const ui = footer()
const ui = createFooterApiFixture()
await runPromptQueue({
footer: ui.api,
@@ -287,7 +211,7 @@ describe("run runtime queue", () => {
})
test("runs queued prompts in order", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
@@ -319,7 +243,7 @@ describe("run runtime queue", () => {
})
test("exposes ordinary in-flight prompts for removal before sending", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const turns: RunPrompt[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
@@ -360,7 +284,7 @@ describe("run runtime queue", () => {
})
test("removing one managed queued prompt preserves the others", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const turns: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
@@ -395,7 +319,7 @@ describe("run runtime queue", () => {
})
test("drains a prompt queued during an in-flight turn", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
let wake: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
@@ -428,7 +352,7 @@ describe("run runtime queue", () => {
})
test("close aborts the active run and drops pending queued work", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const seen: string[] = []
let hit = false
@@ -466,7 +390,7 @@ describe("run runtime queue", () => {
})
test("propagates run errors", async () => {
const ui = footer()
const ui = createFooterApiFixture()
const task = runPromptQueue({
footer: ui.api,
+28 -114
View File
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
import type { FooterApi, FooterEvent, MiniHost } from "../../src/mini/types"
import type { FooterEvent, MiniHost } from "../../src/mini/types"
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
import { createFooterApiFixture } from "./fixture/footer-api"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
function defer<T>() {
@@ -38,55 +40,8 @@ function host(): MiniHost {
}
}
function footer(events: FooterEvent[] = []): FooterApi {
let closed = false
const closes = new Set<() => void>()
const notify = () => {
for (const fn of closes) fn()
}
return {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose(fn) {
if (closed) {
fn()
return () => {}
}
closes.add(fn)
return () => {
closes.delete(fn)
}
},
event(value) {
events.push(value)
},
append() {},
idle() {
return Promise.resolve()
},
close() {
if (closed) {
return
}
closed = true
notify()
},
destroy() {
if (closed) {
return
}
closed = true
notify()
},
}
function footer(events: FooterEvent[] = []) {
return createFooterApiFixture({ events }).api
}
afterEach(() => {
@@ -100,12 +55,7 @@ describe("run interactive runtime", () => {
const streamStarted = defer<void>()
let lifecycle!: LifecycleInput
const settled: Array<{ sessionID: string; formID: string }> = []
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
stubCatalogLists(sdk)
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
const task = runInteractiveDeferredMode(
@@ -195,12 +145,7 @@ describe("run interactive runtime", () => {
const api = footer()
let resolved = 0
api.idle = () => painted.promise
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
stubCatalogLists(sdk)
const task = runInteractiveDeferredMode(
{
@@ -279,38 +224,17 @@ describe("run interactive runtime", () => {
cursor: {},
}) as never,
)
spyOn(sdk.provider, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
}) as never,
)
spyOn(sdk.model, "list").mockImplementation(
() =>
ok({
location: { directory: "/tmp" },
data: [
{
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: { headers: {}, body: {} },
variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
time: { released: 1 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 128000, output: 8192 },
},
],
}) as never,
)
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
stubCatalogLists(sdk, {
providers: [catalogProvider("openai", "OpenAI")],
models: [
catalogModel({
id: "gpt-5",
providerID: "openai",
name: "Little Frank",
variants: ["high"],
}),
],
})
const task = runInteractiveDeferredMode(
{
@@ -391,13 +315,7 @@ describe("run interactive runtime", () => {
const session = spyOn(sdk.session, "get").mockImplementation(
(_request, options) => pending(options?.signal) as never,
)
const response = { location: { directory: "/tmp" }, data: [] }
spyOn(sdk.provider, "list").mockResolvedValue(response as never)
spyOn(sdk.model, "list").mockResolvedValue(response as never)
spyOn(sdk.agent, "list").mockResolvedValue(response as never)
spyOn(sdk.reference, "list").mockResolvedValue(response as never)
spyOn(sdk.command, "list").mockResolvedValue(response as never)
spyOn(sdk.skill, "list").mockResolvedValue(response as never)
stubCatalogLists(sdk)
const task = runInteractiveDeferredMode(
{
@@ -457,13 +375,9 @@ describe("run interactive runtime", () => {
let getDirectory: (() => string) | undefined
let findFiles: ((query: string) => Promise<string[]>) | undefined
let transportLocation: unknown
const response = { location: { directory: "/session", workspaceID: "work-1" }, data: [] }
const providerList = spyOn(sdk.provider, "list").mockResolvedValue(response as never)
const modelList = spyOn(sdk.model, "list").mockResolvedValue(response as never)
const agentList = spyOn(sdk.agent, "list").mockResolvedValue(response as never)
const referenceList = spyOn(sdk.reference, "list").mockResolvedValue(response as never)
const commandList = spyOn(sdk.command, "list").mockResolvedValue(response as never)
const skillList = spyOn(sdk.skill, "list").mockResolvedValue(response as never)
const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" },
})
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
location: {
directory: "/session",
@@ -538,12 +452,12 @@ describe("run interactive runtime", () => {
const query = { location: { directory: "/session", workspace: "work-1" } }
expect(getDirectory?.()).toBe("/session")
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
expect(providerList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(modelList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(agentList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(referenceList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(commandList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(skillList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
})
})
@@ -6,6 +6,7 @@ import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
import { entryGroupKey } from "../../src/mini/scrollback.writer"
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
import type { StreamCommit } from "../../src/mini/types"
import { canonicalToolPart } from "./fixture/tool-part"
type ClaimedCommit = {
snapshot: {
@@ -220,21 +221,6 @@ function error(text: string): StreamCommit {
}
}
function toolPart(name: string, state: SessionMessageAssistantTool["state"], id: string): SessionMessageAssistantTool {
return {
type: "tool",
id,
name,
state,
time:
state.status === "streaming"
? { created: 1 }
: state.status === "completed" || state.status === "error"
? { created: 1, ran: 1, completed: 2 }
: { created: 1, ran: 1 },
}
}
function toolCommit(input: {
tool: string
phase: StreamCommit["phase"]
@@ -256,7 +242,7 @@ function toolCommit(input: {
messageID,
tool: input.tool,
...(input.toolState ? { toolState: input.toolState } : {}),
...(input.state ? { part: toolPart(input.tool, input.state, id) } : {}),
...(input.state ? { part: canonicalToolPart(input.tool, input.state, id) } : {}),
}
}
@@ -11,7 +11,9 @@ import {
type PermissionV2Request,
} from "@opencode-ai/client/promise"
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
import type { StreamCommit } from "../../src/mini/types"
import { createFooterApiFixture } from "./fixture/footer-api"
import { canonicalToolPart } from "./fixture/tool-part"
import { tmpdir } from "../fixture/fixture"
type RunV2Event = EventSubscribeOutput
@@ -91,31 +93,7 @@ function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[
}
function footer() {
const commits: StreamCommit[] = []
const events: FooterEvent[] = []
let closed = false
const api: FooterApi = {
get isClosed() {
return closed
},
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event(value) {
events.push(value)
},
append(value) {
commits.push(value)
},
idle: () => Promise.resolve(),
close() {
closed = true
},
destroy() {
closed = true
},
}
return { api, commits, events }
return createFooterApiFixture()
}
type SessionMessages = MessageListOutput["data"]
@@ -268,18 +246,16 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{
type: "tool" as const,
id: "call_child_source",
name: "shell",
state: {
canonicalToolPart(
"shell",
{
status: "running" as const,
input: { command: "git status --short" },
structured: {},
content: [],
},
time: { created: 1, ran: 1 },
},
"call_child_source",
),
],
time: { created: 1 },
}
@@ -508,8 +484,10 @@ describe("V2 mini transport", () => {
test("sends local file and directory mentions as structured prompt files", async () => {
await using tmp = await tmpdir()
const filePath = path.join(tmp.path, "note.ts")
const contextPath = path.join(tmp.path, "context.txt")
const directoryPath = path.join(tmp.path, "docs")
await Bun.write(filePath, "export const answer = 42\n")
await Bun.write(contextPath, "context body")
await fs.mkdir(directoryPath)
await Bun.write(path.join(directoryPath, "README.md"), "# hello\n")
@@ -573,12 +551,16 @@ describe("V2 mini transport", () => {
},
],
},
files: [],
files: [
{ type: "file", url: pathToFileURL(contextPath).href, filename: "context.txt", mime: "text/plain" },
{ type: "file", url: "file:///tmp/image.png", filename: "image.png", mime: "image/png" },
],
includeFiles: true,
})
expect(request?.text).toBe("Review @note.ts and @docs")
expect(request?.text).toBe('Review @note.ts and @docs\n\n<file name="context.txt">\ncontext body\n</file>')
expect(request?.files).toEqual([
{ uri: "file:///tmp/image.png", name: "image.png" },
{
uri: pathToFileURL(filePath).href,
name: "note.ts",
@@ -2393,10 +2375,19 @@ describe("V2 mini transport", () => {
prompt: {
messageID: "msg_cmd",
text: "/deploy prod",
parts: [],
parts: [
{
type: "file",
url: "file:///tmp/mentioned.txt",
filename: "mentioned.txt",
source: { type: "file", text: { start: 8, end: 12, value: "prod" } },
},
],
command: { name: "deploy", arguments: "prod" },
},
files: [],
files: [
{ type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" },
],
includeFiles: true,
})
@@ -2407,6 +2398,14 @@ describe("V2 mini transport", () => {
arguments: "prod",
agent: "build",
model: { providerID: "test", id: "model" },
files: [
{ uri: "file:///tmp/context.txt", name: "context.txt" },
{
uri: "file:///tmp/mentioned.txt",
name: "mentioned.txt",
mention: { start: 8, end: 12, text: "prod" },
},
],
delivery: "steer",
})
// Selection rides the command payload; no separate client-side switch.
@@ -2845,6 +2844,14 @@ describe("V2 mini transport", () => {
agents: [],
time: { created: 1 },
},
{
id: "msg_child_a",
type: "assistant" as const,
agent: "explore",
model: { providerID: "test", id: "model" },
content: [{ type: "text" as const, text: "child answer" }],
time: { created: 2 },
},
],
},
})
@@ -2890,18 +2897,34 @@ describe("V2 mini transport", () => {
{ sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" },
])
expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
events.push({
id: "evt_child_text",
id: "evt_child_text_replayed",
created: 0,
type: "session.text.delta",
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
ordinal: 0,
delta: "child answer",
delta: "answer",
},
})
while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer")))
await Bun.sleep(0)
expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
events.push({
id: "evt_child_text_suffix",
created: 0,
type: "session.text.delta",
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
ordinal: 0,
delta: " suffix",
},
})
while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix")))
await Bun.sleep(0)
events.push({
+27 -30
View File
@@ -1,33 +1,10 @@
import { describe, expect, test } from "bun:test"
import { writeSessionOutput } from "../../src/mini/stream"
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
function footer() {
const events: FooterEvent[] = []
const commits: StreamCommit[] = []
const api: FooterApi = {
isClosed: false,
onPrompt: () => () => {},
onQueuedRemove: () => () => {},
onClose: () => () => {},
event: (next) => {
events.push(next)
},
append: (next) => {
commits.push(next)
},
idle: () => Promise.resolve(),
close: () => {},
destroy: () => {},
}
return { api, events, commits }
}
import { createFooterApiFixture } from "./fixture/footer-api"
describe("run stream bridge", () => {
test("defaults status patches to running phase", () => {
const out = footer()
const out = createFooterApiFixture()
writeSessionOutput(
{
@@ -35,11 +12,7 @@ describe("run stream bridge", () => {
},
{
commits: [],
footer: {
patch: {
status: "assistant responding",
},
},
updates: [{ type: "stream.patch", patch: { status: "assistant responding" } }],
},
)
@@ -53,4 +26,28 @@ describe("run stream bridge", () => {
},
])
})
test("delivers commits before ordered footer updates", () => {
const out = createFooterApiFixture()
writeSessionOutput(
{ footer: out.api },
{
commits: [{ kind: "assistant", source: "assistant", text: "answer", phase: "progress" }],
updates: [
{ type: "stream.patch", patch: { phase: "idle", status: "" } },
{ type: "stream.subagent", state: { tabs: [], details: {}, permissions: [], forms: [] } },
{ type: "stream.view", view: { type: "prompt" } },
],
},
)
expect(out.calls.map((call) => (call.type === "commit" ? "commit" : call.value.type))).toEqual([
"commit",
"stream.patch",
"stream.subagent",
"stream.view",
])
})
})