tui/mini: consolidate stream and panel internals (#37903)
This commit is contained in:
@@ -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,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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user