Merge remote-tracking branch 'origin/v2' into service-restart

This commit is contained in:
Kit Langton 2026-07-14 18:14:55 -04:00
commit 54e2b7e0a7
40 changed files with 1832 additions and 1007 deletions

View file

@ -4,10 +4,11 @@ import { testRender } from "@opentui/solid"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { EventV2 } from "@opencode-ai/core/event"
import { onMount } from "solid-js"
import { createEffect, onMount, type ParentProps } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useSetLocation } from "../../../src/context/location"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
@ -32,6 +33,24 @@ function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCode
events.emit({ ...event, location: { directory } })
}
function DataProvider(props: ParentProps) {
return (
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
</LocationProvider>
</DataProviderBase>
)
}
function SyncLocation() {
const data = useData()
const setLocation = useSetLocation()
createEffect(() => setLocation(data.location.default()))
return null
}
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
function durable<const Version extends number>(
sessionID: string,
@ -64,16 +83,13 @@ test("bootstraps MCP data for the TUI location", async () => {
try {
await wait(() => requests.length === 2)
expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([
process.cwd(),
process.cwd(),
])
expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([directory, directory])
} finally {
app.renderer.destroy()
}
})
test("refreshes MCP status when a connection settles during bootstrap", async () => {
test("syncs MCP status when a connection settles during bootstrap", async () => {
const events = createEventStream()
let mcpRequests = 0
let resolveModels!: (response: Response) => void
@ -190,9 +206,9 @@ test("refreshes resources into reactive getters", async () => {
expect(data.session.get("ses_test")).toBeUndefined()
expect(data.location.agent.list(location)).toBeUndefined()
await data.session.refresh("ses_test")
await data.session.message.refresh("ses_test")
await data.location.agent.refresh()
await data.session.sync("ses_test")
await data.session.message.sync("ses_test")
await data.location.agent.sync()
expect(data.session.get("ses_test")?.title).toBe("Test session")
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"])
@ -243,7 +259,7 @@ test("applies absolute usage events to session info", async () => {
))
try {
await data.session.refresh(sessionID)
await data.session.sync(sessionID)
emitEvent(events, {
id: "evt_usage_2",
created: 2,
@ -328,7 +344,7 @@ test("truncates committed revert messages without changing lifetime usage", asyn
))
try {
await data.session.refresh(sessionID)
await data.session.sync(sessionID)
emitEvent(events, {
id: "evt_revert_boundary_started",
created: 1,
@ -467,7 +483,7 @@ test("updates session location when moved", async () => {
try {
await mounted
await data.session.refresh("ses_test")
await data.session.sync("ses_test")
emitEvent(events, {
id: "evt_moved_1",
created: 1,
@ -525,7 +541,7 @@ test("restores running manual compaction before applying live deltas", async ()
))
try {
await data.session.message.refresh("session-compaction")
await data.session.message.sync("session-compaction")
expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({
type: "compaction",
status: "running",
@ -548,7 +564,7 @@ test("restores running manual compaction before applying live deltas", async ()
}
})
test("reconnects the event stream and bootstraps fresh data", async () => {
test("reconnects the event stream and resyncs active data", async () => {
const events = createEventStream()
const requests = { active: 0, event: 0, message: 0, model: 0 }
let resolveActive!: (response: Response) => void
@ -621,7 +637,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
try {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
await wait(() => data.session.status("session-stale") === "running")
await data.session.message.refresh("session-stale")
await data.session.message.sync("session-stale")
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
expect(client.connection.status()).toBe("connected")
expect(client.connection.attempt()).toBe(0)
@ -633,6 +649,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
await wait(() => requests.active === 2 && client.connection.status() === "connected", 4000)
resolveActive(json({ data: { "session-new": { type: "running" } } }))
void data.session.message.sync("session-stale")
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
@ -664,8 +681,10 @@ test("completes exploration when a queued prompt is promoted", async () => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
client = useClient()
rows = createSessionRows(() => sessionID)
return <box />
}
@ -683,6 +702,7 @@ test("completes exploration when a queued prompt is promoted", async () => {
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_step_started",
created: 1,
@ -954,7 +974,7 @@ test("tracks session status from active sessions and execution events", async ()
try {
await wait(() => data.session.status("session-active") === "running")
expect(data.session.status("session-idle")).toBe("idle")
await data.session.refresh("session-live")
await data.session.sync("session-live")
settled = true
emitEvent(events, {
@ -1021,7 +1041,7 @@ test("tracks session status from active sessions and execution events", async ()
})
await wait(() => data.session.status("session-live") === "idle")
await data.session.refresh("session-failed")
await data.session.sync("session-failed")
emitEvent(events, {
id: "evt_failed_execution_started",
created: 0,
@ -1184,7 +1204,7 @@ test("tracks session status from active sessions and execution events", async ()
durable: durable("session-manual", 1),
data: { sessionID: "session-manual", inputID: "message-compaction" },
})
await wait(() => data.session.compaction.list("session-manual").includes("message-compaction"))
await wait(() => data.session.pending.list("session-manual").some((item) => item.id === "message-compaction"))
emitEvent(events, {
id: "evt_manual_compaction_started",
created: 1,
@ -1202,10 +1222,8 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-manual", "message-compaction")
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
})
expect(data.session.compaction.list("session-manual")).toEqual([])
const compactionRow = manualRows.find(
(row) => row.type === "message" && row.messageID === "message-compaction",
)
expect(data.session.pending.list("session-manual")).toEqual([])
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
emitEvent(events, {
id: "evt_manual_compaction_ended",
created: 3,
@ -1247,9 +1265,7 @@ test("tracks session status from active sessions and execution events", async ()
const message = data.session.message.get("session-live", "msg_compaction_started")
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
})
const autoCompactionRow = rows.find(
(row) => row.type === "message" && row.messageID === "msg_compaction_started",
)
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
emitEvent(events, {
id: "evt_compaction_ended",
@ -1301,9 +1317,11 @@ test("restores queued compaction from durable pending input", async () => {
}, events)
let data!: ReturnType<typeof useData>
let rows!: ReturnType<typeof createSessionRows>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
rows = createSessionRows(() => sessionID)
return <box />
}
@ -1321,8 +1339,9 @@ test("restores queued compaction from durable pending input", async () => {
))
try {
await wait(() => data.session.compaction.list(sessionID).length === 2)
expect(data.session.compaction.list(sessionID)).toEqual([
await wait(() => client.connection.status() === "connected")
await wait(() => data.session.pending.list(sessionID).length === 2)
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([
"message-compaction-queued",
"message-compaction-later",
])
@ -1359,8 +1378,8 @@ test("restores queued compaction from durable pending input", async () => {
inputID: "message-compaction-queued",
},
})
await wait(() => data.session.compaction.list(sessionID).length === 1)
expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"])
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
emitEvent(events, {
id: "evt_compaction_ended",
@ -1369,15 +1388,12 @@ test("restores queued compaction from durable pending input", async () => {
durable: durable(sessionID, 5),
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
})
expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"])
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
pending = []
emitEvent(events, {
id: "evt_reconnected",
type: "server.connected",
data: {},
})
await wait(() => data.session.compaction.list(sessionID).length === 0)
data.session.pending.invalidate(sessionID)
await data.session.pending.sync(sessionID)
await wait(() => data.session.pending.list(sessionID).length === 0)
} finally {
app.renderer.destroy()
}
@ -1689,7 +1705,7 @@ test("keeps shell state scoped to location", async () => {
try {
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
await data.shell.refresh({ directory: other })
await data.shell.sync({ directory: other })
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
@ -1790,22 +1806,27 @@ test("adds and dismisses permission requests from live events", async () => {
}
})
test("reconciles all pending permission requests when the event stream reconnects", async () => {
test("reconciles active session permissions when the event stream reconnects", async () => {
const events = createEventStream()
let requests = [
{ id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] },
{ id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] },
{ id: "per_old", sessionID: "ses_active", action: "read", resources: ["old.txt"] },
{ id: "per_keep", sessionID: "ses_active", action: "shell", resources: ["bun test"] },
]
let calls = 0
const fetch = createFetch((url) => {
if (url.pathname !== "/api/permission/request") return
if (url.pathname !== "/api/session/ses_active/permission") return
calls++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
return json({ data: requests })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
const client = useClient()
createEffect(() => {
if (client.connection.status() !== "connected") return
void data.session.permission.sync("ses_active")
})
return <box />
}
@ -1822,15 +1843,12 @@ test("reconciles all pending permission requests when the event stream reconnect
))
try {
await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old")
expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep")
await wait(() => data.session.permission.list("ses_active")?.length === 2)
requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }]
requests = [{ id: "per_new", sessionID: "ses_active", action: "edit", resources: ["new.txt"] }]
events.disconnect()
await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new")
expect(data.session.permission.list("ses_old")).toBeUndefined()
expect(data.session.permission.list("ses_keep")).toBeUndefined()
await wait(() => calls === 2 && data.session.permission.list("ses_active")?.[0]?.id === "per_new")
} finally {
app.renderer.destroy()
}
@ -1903,7 +1921,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
})
await wait(() => data.session.form.list("ses_1")?.length === 0)
await data.session.form.refresh("ses_1")
await data.session.form.sync("ses_1")
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"])
} finally {
app.renderer.destroy()
@ -1975,7 +1993,7 @@ test("tracks global forms by location", async () => {
}
})
test("refreshes global forms for the requested location", async () => {
test("syncs global forms once for each requested location", async () => {
const events = createEventStream()
const requests: URL[] = []
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
@ -2025,20 +2043,24 @@ test("refreshes global forms for the requested location", async () => {
await wait(() => client.connection.status() === "connected" && requests.length > 0)
requests.length = 0
await data.session.form.refresh("global", { directory })
await data.session.form.refresh("global", other)
await data.session.form.sync("global", { directory })
await data.session.form.sync("global", other)
expect(requests).toHaveLength(2)
expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory)
expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
expect(requests).toHaveLength(1)
expect(requests[0]?.searchParams.get("location[directory]")).toBe(other.directory)
expect(requests[0]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
data.session.form.invalidate("global", other)
await data.session.form.sync("global", other)
expect(requests).toHaveLength(2)
} finally {
app.renderer.destroy()
}
})
test("refreshes global forms once per loaded location after reconnect", async () => {
test("resyncs global forms only for the active location after reconnect", async () => {
const events = createEventStream()
const requests: URL[] = []
const counts = new Map<string, number>()
@ -2098,58 +2120,54 @@ test("refreshes global forms once per loaded location after reconnect", async ()
))
try {
await wait(
() =>
data.session.form.list("global", home)?.[0]?.id === "frm_default_1" &&
data.session.form.list("global", other)?.[0]?.id === "frm_other_1",
)
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_1")
await data.session.form.sync("global", other)
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
expect(requests).toHaveLength(2)
requests.length = 0
events.disconnect()
await wait(
() =>
data.session.form.list("global", home)?.[0]?.id === "frm_default_2" &&
data.session.form.list("global", other)?.[0]?.id === "frm_other_2",
4000,
)
expect(requests).toHaveLength(2)
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000)
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
expect(requests).toHaveLength(1)
expect(
requests.map((url) => [
url.searchParams.get("location[directory]") ?? directory,
url.searchParams.get("location[workspace]") ?? undefined,
]),
).toEqual([
[home.directory, undefined],
[other.directory, other.workspaceID],
])
).toEqual([[home.directory, undefined]])
} finally {
app.renderer.destroy()
}
})
test("reconciles all pending form requests when the event stream reconnects", async () => {
test("reconciles active session forms when the event stream reconnects", async () => {
const events = createEventStream()
let requests = [
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: formFields },
{ id: "frm_old", sessionID: "ses_active", title: "Input requested", fields: formFields },
{
id: "frm_keep",
sessionID: "ses_keep",
sessionID: "ses_active",
title: "Input requested",
fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
},
]
let calls = 0
const fetch = createFetch((url) => {
if (url.pathname !== "/api/form/request") return
if (url.pathname !== "/api/session/ses_active/form") return
calls++
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
return json({ data: requests })
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
const client = useClient()
createEffect(() => {
if (client.connection.status() !== "connected") return
void data.session.form.sync("ses_active")
})
return <box />
}
@ -2166,15 +2184,12 @@ test("reconciles all pending form requests when the event stream reconnects", as
))
try {
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
await wait(() => data.session.form.list("ses_active")?.length === 2)
requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", fields: formFields }]
requests = [{ id: "frm_new", sessionID: "ses_active", title: "Input requested", fields: formFields }]
events.disconnect()
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
expect(data.session.form.list("ses_old")).toBeUndefined()
expect(data.session.form.list("ses_keep")).toBeUndefined()
await wait(() => calls === 2 && data.session.form.list("ses_active")?.[0]?.id === "frm_new")
} finally {
app.renderer.destroy()
}
@ -2421,7 +2436,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
])
expect(sync.session.input.list(sessionID)).toEqual([messageID])
await sync.session.message.refresh(sessionID)
await sync.session.message.sync(sessionID)
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
emitEvent(events, {
@ -2538,8 +2553,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
const calls = createFetch((url) => {
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
if (match && match[1] !== "active")
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
})
let data!: ReturnType<typeof useData>
let ready!: () => void
@ -2569,13 +2583,13 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
test("groups an orphan child under its missing parent until the root arrives", async () => {
const { data, app } = await mountData({ child: "root" })
try {
await data.session.refresh("child")
await data.session.sync("child")
// Parent info is absent, so the missing parent is the furthest-known ancestor.
expect(data.session.root("child")).toBe("root")
expect(data.session.family("child")).toEqual(["child"])
expect(data.session.family("root")).toEqual(["child"])
await data.session.refresh("root")
await data.session.sync("root")
expect(data.session.root("root")).toBe("root")
// The tentative root entry folds into the now-known root's family.
expect(data.session.family("child")).toEqual(["child", "root"])
@ -2588,17 +2602,17 @@ test("groups an orphan child under its missing parent until the root arrives", a
test("indexes arbitrarily deep nesting under a single root", async () => {
const { data, app } = await mountData({ grandchild: "child", child: "root" })
try {
await data.session.refresh("grandchild")
await data.session.sync("grandchild")
expect(data.session.root("grandchild")).toBe("child")
expect(data.session.family("grandchild")).toEqual(["grandchild"])
await data.session.refresh("child")
await data.session.sync("child")
// grandchild's tentative family (keyed by the missing "child") merges up
// toward the still-missing "root".
expect(data.session.root("child")).toBe("root")
expect(data.session.family("grandchild")).toEqual(["grandchild", "child"])
await data.session.refresh("root")
await data.session.sync("root")
expect(data.session.root("grandchild")).toBe("root")
expect(data.session.root("child")).toBe("root")
expect(data.session.family("root")).toEqual(["grandchild", "child", "root"])
@ -2608,14 +2622,11 @@ test("indexes arbitrarily deep nesting under a single root", async () => {
})
test("totals family cost for roots and keeps subagent cost scoped", async () => {
const { data, app } = await mountData(
{ grandchild: "child", child: "root" },
{ root: 1, child: 2, grandchild: 3 },
)
const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
try {
await data.session.refresh("grandchild")
await data.session.refresh("child")
await data.session.refresh("root")
await data.session.sync("grandchild")
await data.session.sync("child")
await data.session.sync("root")
expect(data.session.cost("root")).toBe(6)
expect(data.session.cost("child")).toBe(2)
@ -2628,15 +2639,15 @@ test("totals family cost for roots and keeps subagent cost scoped", async () =>
test("re-registering an existing session is idempotent", async () => {
const { data, app } = await mountData({ grandchild: "child", child: "root" })
try {
await data.session.refresh("grandchild")
await data.session.refresh("child")
await data.session.refresh("root")
await data.session.sync("grandchild")
await data.session.sync("child")
await data.session.sync("root")
const before = data.session.family("root")
expect(before).toEqual(["grandchild", "child", "root"])
await data.session.refresh("child")
await data.session.refresh("root")
await data.session.refresh("grandchild")
await data.session.sync("child")
await data.session.sync("root")
await data.session.sync("grandchild")
expect(data.session.family("root")).toEqual(before)
expect(data.session.family("root")).toHaveLength(3)
} finally {
@ -2647,8 +2658,8 @@ test("re-registering an existing session is idempotent", async () => {
test("stops at the last non-repeating ancestor on a parent cycle", async () => {
const { data, app } = await mountData({ x: "y", y: "x" })
try {
await data.session.refresh("x")
await data.session.refresh("y")
await data.session.sync("x")
await data.session.sync("y")
// Does not hang; walking up from "y" stops before re-entering "x".
expect(data.session.root("y")).toBe("x")
expect(data.session.family("y")).toEqual(["x", "y"])