fix: stabilize v2 runtime behavior

This commit is contained in:
Dax Raad 2026-06-26 20:56:36 -04:00
commit 655adbf46e
35 changed files with 742 additions and 425 deletions

View file

@ -1,12 +1,12 @@
import { describe, expect, test } from "bun:test"
import Notifications from "../../../../src/feature-plugins/system/notifications"
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
import type { PermissionRequest, QuestionRequest, Session, V2Event } from "@opencode-ai/sdk/v2"
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
async function setup() {
const notifications: TuiAttentionNotifyInput[] = []
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
const handlers = new Map<V2Event["type"], ((event: V2Event) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
@ -33,9 +33,12 @@ async function setup() {
},
},
event: {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
on: <Type extends V2Event["type"]>(
type: Type,
handler: (event: Extract<V2Event, { type: Type }>) => void,
) => {
const list = handlers.get(type) ?? []
const wrapped = handler as (event: Event) => void
const wrapped = handler as (event: V2Event) => void
list.push(wrapped)
handlers.set(type, list)
return () => {
@ -58,7 +61,7 @@ async function setup() {
return {
notifications,
emit(event: Event) {
emit(event: V2Event) {
for (const handler of handlers.get(event.type) ?? []) handler(event)
},
}
@ -83,11 +86,11 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
}
}
function stepStarted(id: string, sessionID = "session"): Event {
function stepStarted(id: string, sessionID = "session"): V2Event {
return {
id,
type: "session.next.step.started",
properties: {
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
@ -97,11 +100,11 @@ function stepStarted(id: string, sessionID = "session"): Event {
}
}
function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
function stepEnded(id: string, sessionID = "session", finish = "stop"): V2Event {
return {
id,
type: "session.next.step.ended",
properties: {
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
@ -112,11 +115,11 @@ function stepEnded(id: string, sessionID = "session", finish = "stop"): Event {
}
}
function stepFailed(id: string, sessionID = "session"): Event {
function stepFailed(id: string, sessionID = "session"): V2Event {
return {
id,
type: "session.next.step.failed",
properties: {
data: {
sessionID,
assistantMessageID: `msg_${id}`,
timestamp: 0,
@ -143,8 +146,8 @@ describe("internal notifications TUI plugin", () => {
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
})
@ -152,23 +155,23 @@ describe("internal notifications TUI plugin", () => {
test("dedupes pending questions and permissions until they are resolved", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-2", type: "question.asked", data: question("question-1") })
harness.emit({
id: "event-3",
type: "question.replied",
properties: { sessionID: "session", requestID: "question-1", answers: [] },
data: { sessionID: "session", requestID: "question-1", answers: [] },
})
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
harness.emit({ id: "event-4", type: "question.asked", data: question("question-1") })
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
harness.emit({ id: "event-5", type: "permission.asked", data: permission("permission-1") })
harness.emit({ id: "event-6", type: "permission.asked", data: permission("permission-1") })
harness.emit({
id: "event-7",
type: "permission.replied",
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
data: { sessionID: "session", requestID: "permission-1", reply: "once" },
})
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
harness.emit({ id: "event-8", type: "permission.asked", data: permission("permission-1") })
expect(harness.notifications).toEqual([
questionNotification,
@ -198,7 +201,7 @@ describe("internal notifications TUI plugin", () => {
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
const harness = await setup()
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
harness.emit({ id: "event-1", type: "question.asked", data: question("question-1", "subagent") })
harness.emit(stepStarted("event-2", "subagent"))
harness.emit(stepEnded("event-3", "subagent"))
@ -242,13 +245,13 @@ describe("internal notifications TUI plugin", () => {
harness.emit({
id: "event-2",
type: "session.error",
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
data: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
})
harness.emit(stepStarted("event-3", "timeout"))
harness.emit({
id: "event-4",
type: "session.error",
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
data: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
})
expect(harness.notifications).toEqual([

View file

@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { collapseToolOutput } from "../../../src/util/collapse-tool-output"
test("limits command input and output to the same line budget", () => {
const command = Array.from({ length: 8 }, (_, index) => `command ${index + 1}`).join("\n")
const output = Array.from({ length: 4 }, (_, index) => `output ${index + 1}`).join("\n")
const collapsed = collapseToolOutput(`$ ${command}\n\n${output}`, 10, 1_000)
expect(collapsed.overflow).toBe(true)
expect(collapsed.output.split("\n")).toHaveLength(10)
expect(collapsed.output).toContain("$ command 1")
expect(collapsed.output).toContain("command 8\n\noutput 1…")
expect(collapsed.output).not.toContain("output 2")
})

View file

@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import type { V2Event } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
@ -17,12 +17,8 @@ async function wait(fn: () => boolean, timeout = 2000) {
}
}
function global(payload: Event): GlobalEvent {
return { directory, project: "proj_test", payload }
}
function emitEvent(events: ReturnType<typeof createEventStream>, payload: Event) {
events.emit(global(payload))
function emitEvent(events: ReturnType<typeof createEventStream>, event: V2Event) {
events.emit({ ...event, location: { directory } })
}
test("refreshes resources into reactive getters", async () => {
@ -205,7 +201,7 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_started",
type: "session.next.step.started",
properties: {
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 1,
@ -218,7 +214,7 @@ test("tracks session status from active sessions and execution events", async ()
emitEvent(events, {
id: "evt_step_ended",
type: "session.next.step.ended",
properties: {
data: {
sessionID: "session-live",
assistantMessageID: "message-live",
timestamp: 2,
@ -291,7 +287,7 @@ test("refreshes integrations after integration updates", async () => {
expect(data.location.integration.list()).toEqual([])
const before = { ...requests }
emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} })
emitEvent(events, { id: "evt_integration", type: "integration.updated", data: {} })
await wait(() => data.location.integration.list()?.length === 1)
await wait(() => requests.model > before.model && requests.provider > before.provider)
expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" })
@ -329,7 +325,7 @@ test("refreshes effective catalog data after catalog updates", async () => {
try {
await wait(() => requests.model > 0 && requests.provider > 0)
const before = { ...requests }
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} })
emitEvent(events, { id: "evt_catalog", type: "catalog.updated", data: {} })
await wait(() => requests.model > before.model && requests.provider > before.provider)
} finally {
app.renderer.destroy()
@ -374,7 +370,7 @@ test("refreshes references after updates", async () => {
try {
await mounted
await wait(() => requests === 1)
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", properties: {} })
emitEvent(events, { id: "evt_reference_1", type: "reference.updated", data: {} })
await wait(() => data.location.reference.list()?.length === 1)
expect(data.location.reference.list()?.[0]?.name).toBe("docs")
} finally {
@ -409,7 +405,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_asked_1",
type: "permission.v2.asked",
properties: {
data: {
id: "per_1",
sessionID: "ses_1",
action: "bash",
@ -419,7 +415,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_asked_2",
type: "permission.v2.asked",
properties: {
data: {
id: "per_2",
sessionID: "ses_1",
action: "read",
@ -431,7 +427,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_replied_1",
type: "permission.v2.replied",
properties: { sessionID: "ses_1", requestID: "per_1", reply: "once" },
data: { 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")
@ -439,7 +435,7 @@ test("adds and dismisses permission requests from live events", async () => {
emitEvent(events, {
id: "evt_permission_replied_2",
type: "permission.v2.replied",
properties: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
data: { sessionID: "ses_1", requestID: "per_2", reply: "reject" },
})
await wait(() => data.session.permission.list("ses_1")?.length === 0)
} finally {
@ -474,7 +470,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_asked_1",
type: "question.v2.asked",
properties: {
data: {
id: "que_1",
sessionID: "ses_1",
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
@ -483,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_asked_2",
type: "question.v2.asked",
properties: {
data: {
id: "que_2",
sessionID: "ses_1",
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
@ -494,7 +490,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_replied_1",
type: "question.v2.replied",
properties: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
data: { 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")
@ -502,7 +498,7 @@ test("adds and dismisses question requests from live events", async () => {
emitEvent(events, {
id: "evt_question_rejected_2",
type: "question.v2.rejected",
properties: { sessionID: "ses_1", requestID: "que_2" },
data: { sessionID: "ses_1", requestID: "que_2" },
})
await wait(() => data.session.question.list("ses_1")?.length === 0)
} finally {
@ -542,12 +538,12 @@ test("settles pending tools when a live failure arrives", async () => {
emitEvent(events, {
id: "evt_agent_1",
type: "session.next.agent.switched",
properties: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
data: { sessionID: "session-1", messageID: "msg_agent_1", timestamp: 0, agent: "build" },
})
emitEvent(events, {
id: "evt_model_1",
type: "session.next.model.switched",
properties: {
data: {
sessionID: "session-1",
messageID: "msg_model_1",
timestamp: 0,
@ -557,7 +553,7 @@ test("settles pending tools when a live failure arrives", async () => {
emitEvent(events, {
id: "evt_step_started_1",
type: "session.next.step.started",
properties: {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 1,
@ -568,7 +564,7 @@ test("settles pending tools when a live failure arrives", async () => {
emitEvent(events, {
id: "evt_input_1",
type: "session.next.tool.input.started",
properties: {
data: {
sessionID: "session-1",
assistantMessageID: "msg_explicit_assistant_9",
timestamp: 2,
@ -579,7 +575,7 @@ test("settles pending tools when a live failure arrives", async () => {
emitEvent(events, {
id: "evt_called_1",
type: "session.next.tool.called",
properties: {
data: {
sessionID: "session-1",
timestamp: 2,
assistantMessageID: "msg_explicit_assistant_9",
@ -592,7 +588,7 @@ test("settles pending tools when a live failure arrives", async () => {
emitEvent(events, {
id: "evt_failed_1",
type: "session.next.tool.failed",
properties: {
data: {
sessionID: "session-1",
timestamp: 3,
assistantMessageID: "msg_explicit_assistant_9",
@ -673,7 +669,7 @@ test("renders admitted prompts only after they become model-visible", async () =
emitEvent(events, {
id: "evt_admitted_1",
type: "session.next.prompt.admitted",
properties: {
data: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 0,
@ -686,7 +682,7 @@ test("renders admitted prompts only after they become model-visible", async () =
emitEvent(events, {
id: "evt_prompted_1",
type: "session.next.prompted",
properties: {
data: {
sessionID: "session-1",
messageID: "msg_user_1",
timestamp: 0,
@ -744,7 +740,7 @@ test("projects live context updates with their message ID", async () => {
emitEvent(events, {
id: "evt_context_1",
type: "session.next.context.updated",
properties: {
data: {
sessionID: "session-1",
messageID: "msg_context_1",
timestamp: 1,

View file

@ -89,6 +89,39 @@ test("groups across empty assistant reasoning parts", () => {
])
})
test("completes exploration groups when another row follows", () => {
const finished = assistant("assistant-2", [
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
])
finished.finish = "stop"
const messages: SessionMessage[] = [
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
]),
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
finished,
]
expect(reduceSessionRows(messages)).toEqual([
{
type: "group",
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "message", messageID: "user-1" },
{
type: "group",
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
},
{ type: "assistant-footer", messageID: "assistant-2" },
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",

View file

@ -1,10 +1,10 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project"
import { SDKProvider } from "../../../src/context/sdk"
import { SDKProvider, useSDK } from "../../../src/context/sdk"
import { useEvent } from "../../../src/context/event"
import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk"
import { TestTuiContexts } from "../../fixture/tui-environment"
@ -19,41 +19,40 @@ async function wait(fn: () => boolean, timeout = 2000) {
}
}
function event(payload: Event, input: { directory: string; project?: string; workspace?: string }): GlobalEvent {
function event(payload: V2Event, input: { directory: string; project?: string; workspace?: string }): V2Event {
return {
directory: input.directory,
project: input.project,
workspace: input.workspace,
payload,
...payload,
location: { directory: input.directory, workspaceID: input.workspace },
}
}
function vcs(branch: string): Event {
function vcs(branch: string): V2Event {
return {
id: `evt_vcs_${branch}`,
type: "vcs.branch.updated",
properties: {
data: {
branch,
},
}
}
function update(version: string): Event {
function update(version: string): V2Event {
return {
id: `evt_update_${version}`,
type: "installation.update-available",
properties: {
data: {
version,
},
}
}
async function mount() {
async function mount(reload?: () => Promise<OpencodeClient>) {
const events = createEventStream()
const calls = createFetch(undefined, events)
const seen: Event[] = []
const seen: V2Event[] = []
const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
let sdk!: ReturnType<typeof useSDK>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
@ -61,11 +60,12 @@ async function mount() {
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)}>
<SDKProvider client={createClient(calls.fetch)} reload={reload}>
<ProjectProvider>
<Probe
onReady={async (ctx) => {
project = ctx.project
sdk = ctx.sdk
await project.sync()
done()
}}
@ -78,15 +78,16 @@ async function mount() {
))
await ready
return { app, emit: events.emit, project, seen, workspaces }
return { app, emit: events.emit, project, sdk, seen, workspaces }
}
function Probe(props: {
seen: Event[]
seen: V2Event[]
workspaces: Array<string | undefined>
onReady: (ctx: { project: ReturnType<typeof useProject> }) => void
onReady: (ctx: { project: ReturnType<typeof useProject>; sdk: ReturnType<typeof useSDK> }) => void
}) {
const project = useProject()
const sdk = useSDK()
const event = useEvent()
onMount(() => {
@ -94,7 +95,7 @@ function Probe(props: {
props.seen.push(evt)
props.workspaces.push(workspace)
})
props.onReady({ project })
props.onReady({ project, sdk })
})
return <box />
@ -109,7 +110,7 @@ describe("useEvent", () => {
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("main")])
expect(seen).toEqual([event(vcs("main"), { directory: "/tmp/other", workspace: "ws_a" })])
expect(workspaces).toEqual(["ws_a"])
} finally {
app.renderer.destroy()
@ -125,7 +126,7 @@ describe("useEvent", () => {
await wait(() => seen.length === 1)
expect(seen).toEqual([vcs("ws")])
expect(seen).toEqual([event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_b" })])
} finally {
app.renderer.destroy()
}
@ -140,7 +141,54 @@ describe("useEvent", () => {
await wait(() => seen.length === 1)
expect(seen).toEqual([update("1.2.3")])
expect(seen).toEqual([event(update("1.2.3"), { directory: "global" })])
} finally {
app.renderer.destroy()
}
})
test("reloads the host and reconnects the event stream", async () => {
let calls = 0
const events = createEventStream()
const replacement = createClient(createFetch(undefined, events).fetch)
const { app, sdk, seen } = await mount(async () => {
calls += 1
return replacement
})
try {
await wait(() => sdk.connection.status() === "connected")
await sdk.reload?.()
await wait(() => sdk.connection.status() === "connected")
events.emit(event(vcs("reloaded"), { directory: "/tmp/reloaded" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded"))
expect(calls).toBe(1)
expect(sdk.client).toBe(replacement)
} finally {
app.renderer.destroy()
}
})
test("keeps the current event stream alive while the host reload is pending", async () => {
let complete!: (client: OpencodeClient) => void
const pending = new Promise<OpencodeClient>((resolve) => {
complete = resolve
})
const replacementEvents = createEventStream()
const replacement = createClient(createFetch(undefined, replacementEvents).fetch)
const { app, emit, sdk, seen } = await mount(() => pending)
try {
await wait(() => sdk.connection.status() === "connected")
const reload = sdk.reload?.()
emit(event(vcs("during-reload"), { directory: "/tmp/reload" }))
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "during-reload"))
expect(sdk.connection.status()).toBe("connected")
complete(replacement)
await reload
expect(sdk.client).toBe(replacement)
} finally {
app.renderer.destroy()
}