Compare commits

...

6 Commits

Author SHA1 Message Date
Kit Langton 0672a8c146 chore: format wire fixtures 2026-08-21 15:40:47 -04:00
Kit Langton ec23ea1564 test(tui): isolate lifecycle storage 2026-08-21 15:38:58 -04:00
Kit Langton e91951785c fix(client): brand optimistic session state 2026-08-21 15:38:57 -04:00
Kit Langton 214b12bbe3 feat(client): migrate consumers to branded IDs 2026-08-21 15:38:57 -04:00
Kit Langton c0718059b7 feat(client): generate branded ID outputs 2026-08-21 15:38:56 -04:00
Kit Langton faf1029723 feat(client): preserve registered output brands 2026-08-21 15:38:55 -04:00
99 changed files with 2636 additions and 1739 deletions
@@ -13,6 +13,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { wire, type Wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { installSseTransport } from "../../utils/sse-transport"
import { expectSessionReady } from "../../utils/waits"
@@ -254,7 +255,7 @@ function describeEvent(event: OpenCodeEvent) {
export function event(
type: "session.status",
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
data: Wire<Extract<OpenCodeEvent, { type: "session.status" }>["data"]>,
): OpenCodeEvent {
return makeEvent(type, data)
}
@@ -465,7 +466,7 @@ export function userMessage(
): SessionMessageUser {
const id = input.id ?? userID
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
return {
return wire<SessionMessageUser>({
id,
type: "user",
time: { created: input.created ?? 1700000000000 },
@@ -499,7 +500,7 @@ export function userMessage(
]
}),
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
}
})
}
export function assistantMessage(
@@ -519,7 +520,7 @@ export function assistantMessage(
const ordinals = { text: 0, reasoning: 0 }
const content = parts.map((part) => messageContent(part, id, ordinals))
nextOrdinals.set(id, ordinals)
return {
return wire<SessionMessageAssistant>({
id,
type: "assistant",
metadata: { parentID: input.parentID ?? userID },
@@ -531,7 +532,7 @@ export function assistantMessage(
tokens,
...(input.completed === false ? {} : { finish: "stop" as const }),
...(input.error ? { error: input.error } : {}),
}
})
}
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
@@ -643,8 +644,8 @@ export function project() {
}
}
export function session(input: Partial<Session> = {}): Session {
return {
export function session(input: Partial<Wire<Session>> = {}): Session {
return wire<Session>({
id: sessionID,
projectID,
location: { directory },
@@ -653,7 +654,7 @@ export function session(input: Partial<Session> = {}): Session {
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1700000000000, updated: 1700000000000 },
...input,
}
})
}
function messageContent(
@@ -829,7 +830,7 @@ function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef
function makeEvent<Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
data: Wire<Extract<OpenCodeEvent, { type: Type }>["data"]>,
): OpenCodeEvent {
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
@@ -1,5 +1,6 @@
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test"
import { wire } from "@/test-fixture"
import { expectSessionTitle } from "../../utils/waits"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
@@ -12,16 +13,19 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
const userID = "msg_parent_hydration_user"
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
const user = {
const userSeed = fixture.messages[fixture.targetID][0]
if (userSeed?.type !== "user") throw new Error("Expected the first target fixture message to be a user message")
const user = wire<SessionMessageUser>({
...userSeed,
id: userID,
time: { created: 1700001000000 },
} satisfies SessionMessageInfo
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
})
const assistantSeed = fixture.messages[fixture.targetID][3]
if (assistantSeed?.type !== "assistant")
throw new Error("Expected the fourth target fixture message to be an assistant message")
const assistants = Array.from({ length: 14 }, (_, index) => {
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
return {
return wire<SessionMessageAssistant>({
...assistantSeed,
id: messageID,
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
@@ -30,7 +34,7 @@ const assistants = Array.from({ length: 14 }, (_, index) => {
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
: part,
),
} satisfies SessionMessageInfo
})
})
const messages = [user, ...assistants]
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
@@ -1,6 +1,13 @@
import { base64Encode } from "@opencode-ai/util/encode"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
JsonValue,
OpenCodeEvent,
SessionMessageAssistant,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import type { Page } from "@playwright/test"
import { wire, type Wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
import { expect } from "../benchmark"
@@ -17,13 +24,16 @@ const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = OpenCodeEvent
type TextStartedEvent = Extract<OpenCodeEvent, { type: "session.text.started" }>
type TextDeltaEvent = Extract<OpenCodeEvent, { type: "session.text.delta" }>
type TimelineEventSeed = Pick<Wire<TextStartedEvent>, "type" | "data"> | Pick<Wire<TextDeltaEvent>, "type" | "data">
const userMessage = {
const userMessage = wire<SessionMessageUser>({
id: userMessageID,
type: "user",
time: { created: 1700000000000 },
text: "Please edit the file.",
} satisfies SessionMessageInfo
})
const editPart: ToolSeed = {
id: editPartID,
@@ -39,20 +49,14 @@ const editPart: ToolSeed = {
content: [{ type: "text", text: "Edited src/regression.ts" }],
metadata: {
files: [
currentFile(
"src/regression.ts",
"export const value = 'before'\n",
"export const value = 'after'\n",
1,
1,
),
currentFile("src/regression.ts", "export const value = 'before'\n", "export const value = 'after'\n", 1, 1),
],
},
},
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
}
const assistantMessage = {
const assistantMessage = wire<SessionMessageAssistant>({
id: assistantMessageID,
type: "assistant",
time: { created: 1700000001000 },
@@ -61,7 +65,7 @@ const assistantMessage = {
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
content: [toolContent(editPart)],
} satisfies SessionMessageInfo
})
export async function setupTimelineBenchmark(
page: Page,
@@ -155,23 +159,29 @@ export async function setupTimelineBenchmark(
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
return [
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
timelineEvent("session.text.delta", {
sessionID,
assistantMessageID,
ordinal: 0,
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
timelineEvent({ type: "session.text.started", data: { sessionID, assistantMessageID, ordinal: 0 } }),
timelineEvent({
type: "session.text.delta",
data: {
sessionID,
assistantMessageID,
ordinal: 0,
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
},
}),
]
}
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
return Array.from({ length: deltaCount }, (_, index) =>
timelineEvent("session.text.delta", {
sessionID,
assistantMessageID,
ordinal: 0,
delta: streamChunk(index + 1, deltaCount + 1),
timelineEvent({
type: "session.text.delta",
data: {
sessionID,
assistantMessageID,
ordinal: 0,
delta: streamChunk(index + 1, deltaCount + 1),
},
}),
)
}
@@ -182,7 +192,7 @@ function performanceTurn(index: number) {
const assistantID = `msg_0000_${suffix}_b_assistant`
const before = historicalSource(index, false)
const after = historicalSource(index, true)
const parts = [
const parts: ContentSeed[] = [
...(index % 5 === 0
? [
{
@@ -192,7 +202,7 @@ function performanceTurn(index: number) {
type: "reasoning",
text: `Reviewing the existing implementation. ${"constraint analysis ".repeat(20)}`,
time: { start: 1690000001000 + index * 2_000, end: 1690000001200 + index * 2_000 },
},
} satisfies ContentSeed,
]
: []),
{
@@ -201,7 +211,7 @@ function performanceTurn(index: number) {
messageID: assistantID,
type: "text",
text: historicalMarkdown(index),
},
} satisfies ContentSeed,
...(index % 8 === 0
? [
{
@@ -221,7 +231,7 @@ function performanceTurn(index: number) {
ran: 1690000001200 + index * 2_000,
completed: 1690000001400 + index * 2_000,
},
},
} satisfies ContentSeed,
]
: []),
...(index % 12 === 0
@@ -241,7 +251,7 @@ function performanceTurn(index: number) {
ran: 1690000001400 + index * 2_000,
completed: 1690000001500 + index * 2_000,
},
},
} satisfies ContentSeed,
]
: []),
...(index % 16 === 0
@@ -267,11 +277,11 @@ function performanceTurn(index: number) {
ran: 1690000001500 + index * 2_000,
completed: 1690000001700 + index * 2_000,
},
},
} satisfies ContentSeed,
]
: []),
] as unknown as ContentSeed[]
return [
]
return wire<SessionMessageInfo[]>([
{
id: userID,
type: "user",
@@ -298,7 +308,7 @@ function performanceTurn(index: number) {
return toolContent(part)
}),
},
] satisfies SessionMessageInfo[]
])
}
type ToolSeed = {
@@ -338,20 +348,22 @@ function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number]
let eventSequence = 0
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
durable = false,
): Extract<OpenCodeEvent, { type: Type }> {
function timelineEvent(seed: TimelineEventSeed): TextStartedEvent | TextDeltaEvent {
eventSequence++
return {
if (seed.type === "session.text.started")
return wire<TextStartedEvent>({
id: `evt_timeline_benchmark_${eventSequence}`,
created: 1700000002000 + eventSequence,
...seed,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version: 1 },
})
return wire<TextDeltaEvent>({
id: `evt_timeline_benchmark_${eventSequence}`,
created: 1700000002000 + eventSequence,
type,
data,
...seed,
location: { directory },
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
} as unknown as Extract<OpenCodeEvent, { type: Type }>
})
}
function historicalMarkdown(index: number) {
@@ -1,4 +1,11 @@
import { createTwoFilesPatch } from "diff"
import { wire } from "@/test-fixture"
import type {
JsonValue,
SessionMessageAssistant,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
const words = [
"alpha",
@@ -64,13 +71,13 @@ function id(prefix: string, value: number) {
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
const messageID = id("msg_user", index)
return {
return wire<SessionMessageUser>({
id: messageID,
type: "user",
time: { created: 1700000000000 + index * 10_000 },
text: lorem(index, textLength),
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
}
})
}
function assistantMessage(
@@ -80,7 +87,7 @@ function assistantMessage(
parts: MessagePart[],
): SessionMessageInfo {
const messageID = id("msg_assistant", index)
return {
return wire<SessionMessageAssistant>({
id: messageID,
type: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
@@ -90,7 +97,7 @@ function assistantMessage(
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: parts.map(messageContent),
}
})
}
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
@@ -151,10 +158,7 @@ function toolPart(
metadataOverride ??
(tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
files: [patchFile(index, "modified"), patchFile(index + 1, index % 2 === 0 ? "added" : "deleted")],
}
: tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
@@ -232,14 +236,20 @@ function turn(index: number): SessionMessageInfo[] {
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
? [
toolPart(
index,
3,
"edit",
{ path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" },
700,
),
]
: []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
: []),
...(index % 8 === 0 ? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] : []),
...(index % 7 === 0
? [toolPart(index, 4, "shell", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
@@ -389,4 +399,3 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
cursor: start > 0 ? messages[start].id : undefined,
}
}
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,5 +1,7 @@
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { expect, test, type Page, type Route } from "@playwright/test"
import { wire, type Wire } from "@/test-fixture"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
@@ -81,7 +83,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
await transport.waitForConnection()
await transport.send({
const backgroundPermission: Wire<OpenCodeEvent> = {
id: "evt_permission_background_a",
created: 1700000001000,
type: "permission.asked",
@@ -94,7 +96,8 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
metadata: {},
save: [],
},
})
}
await transport.send(wire<OpenCodeEvent>(backgroundPermission))
await expect
.poll(() => permissionResponses)
@@ -108,7 +111,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
},
])
await transport.send({
const childBackgroundPermission: Wire<OpenCodeEvent> = {
id: "evt_permission_background_a_child",
created: 1700000002000,
type: "permission.asked",
@@ -121,7 +124,8 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
metadata: {},
save: [],
},
})
}
await transport.send(wire<OpenCodeEvent>(childBackgroundPermission))
await expect
.poll(() => permissionResponses)
@@ -1,5 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -108,14 +110,14 @@ async function openReview(page: Page) {
return []
},
pageMessages: () => ({
items: [
items: wire<SessionMessageInfo[]>([
{
id: "msg_review_image_flash_regression",
type: "user",
time: { created: 1700000000000 },
text: "Review this change.",
},
],
]),
}),
})
@@ -1,5 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
@@ -121,14 +123,14 @@ async function openReview(page: Page) {
},
],
pageMessages: () => ({
items: [
items: wire<SessionMessageInfo[]>([
{
id: "msg_review_line_comment_regression",
type: "user",
time: { created: 1700000000000 },
text: "Review this change.",
},
],
]),
}),
})
@@ -1,6 +1,7 @@
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { base64Encode } from "@opencode-ai/util/encode"
import { expect, test } from "@playwright/test"
import { wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -8,7 +9,7 @@ const directory = "C:/OpenCode/SessionMessageRevert"
const projectID = "proj_session_message_revert"
const sessionID = "ses_session_message_revert"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = [
const messages = wire<SessionMessageInfo[]>([
{ id: "msg_first", type: "user", text: "First prompt", time: { created: 1 } },
{
id: "msg_first_reply",
@@ -19,7 +20,7 @@ const messages = [
time: { created: 2, completed: 3 },
},
{ id: "msg_second", type: "user", text: "Second prompt", time: { created: 4 } },
] satisfies SessionMessageInfo[]
])
test("reverts directly to the selected user message", async ({ page }) => {
const staged: { sessionID: string; messageID: string }[] = []
@@ -1,5 +1,7 @@
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test"
import { wire, type Wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
@@ -138,7 +140,7 @@ test("restores the draft caret before typing after a request dock closes", async
}),
)
.toBe(cursor)
await transport.send({
const created: Wire<OpenCodeEvent> = {
id: "evt_form_created",
created: 1700000001000,
type: "form.created",
@@ -161,18 +163,20 @@ test("restores the draft caret before typing after a request dock closes", async
],
},
},
})
}
await transport.send(wire<OpenCodeEvent>(created))
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible()
await expect(editor).toHaveCount(0)
await transport.send({
const cancelled: Wire<OpenCodeEvent> = {
id: "evt_form_cancelled",
created: 1700000002000,
type: "form.cancelled",
location: { directory },
data: { sessionID, id: "frm_question_caret" },
})
}
await transport.send(wire<OpenCodeEvent>(cancelled))
await expect(question).toHaveCount(0)
await expect(editor).toBeVisible()
await page.keyboard.press("x")
@@ -1,5 +1,12 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
JsonValue,
OpenCodeEvent,
SessionMessageAssistant,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import { wire, type Wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import { createTwoFilesPatch } from "diff"
@@ -25,12 +32,12 @@ declare global {
}
}
const userMessage = {
const userMessage = wire<SessionMessageUser>({
id: userMessageID,
type: "user",
time: { created: 1700000000000 },
text: "Please edit the file.",
} satisfies SessionMessageInfo
})
const editPart = {
id: editPartID,
@@ -72,7 +79,7 @@ const streamedTextPart = {
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
const assistantMessage = wire<SessionMessageAssistant>({
id: assistantMessageID,
type: "assistant",
time: { created: 1700000001000 },
@@ -81,7 +88,7 @@ const assistantMessage = {
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
content: [toolContent(editPart)],
} satisfies SessionMessageInfo
})
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
@@ -330,56 +337,61 @@ let eventSequence = -1
function textEvents(): OpenCodeEvent[] {
return [
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
eventValue(
"session.text.ended",
{
eventValue(1, (envelope) => ({
...envelope,
type: "session.text.started",
data: { sessionID, assistantMessageID, ordinal: 0 },
})),
eventValue(1, (envelope) => ({
...envelope,
type: "session.text.ended",
data: {
sessionID,
assistantMessageID,
ordinal: 0,
text: streamedTextPart.text,
},
1,
),
})),
]
}
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
return [
eventValue(
"session.tool.input.started",
{
eventValue(1, (envelope) => ({
...envelope,
type: "session.tool.input.started",
data: {
sessionID,
assistantMessageID,
id: part.callID,
name: part.tool,
},
1,
),
eventValue(
"session.tool.input.ended",
{
})),
eventValue(1, (envelope) => ({
...envelope,
type: "session.tool.input.ended",
data: {
sessionID,
assistantMessageID,
id: part.callID,
text: JSON.stringify(part.state.input),
},
1,
),
eventValue(
"session.tool.called",
{
})),
eventValue(1, (envelope) => ({
...envelope,
type: "session.tool.called",
data: {
sessionID,
assistantMessageID,
id: part.callID,
input: part.state.input,
executed: true,
},
1,
),
eventValue(
"session.tool.success",
{
})),
eventValue(2, (envelope) => ({
...envelope,
type: "session.tool.success",
data: {
sessionID,
assistantMessageID,
id: part.callID,
@@ -387,25 +399,30 @@ function toolEvents(part: typeof editPart): OpenCodeEvent[] {
metadata: part.state.metadata as Record<string, JsonValue>,
executed: true,
},
2,
),
})),
]
}
function eventValue<Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
version: 1 | 2,
): Extract<OpenCodeEvent, { type: Type }> {
type EventEnvelope<Version extends 1 | 2> = {
id: string
created: number
location: { directory: string }
durable: { aggregateID: string; seq: number; version: Version }
}
function eventValue<Version extends 1 | 2>(
version: Version,
build: (envelope: EventEnvelope<Version>) => Wire<OpenCodeEvent>,
): OpenCodeEvent {
eventSequence++
return {
id: `evt_collapse_${eventSequence}`,
created: 1700000002000 + eventSequence,
type,
data,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version },
} as unknown as Extract<OpenCodeEvent, { type: Type }>
return wire<OpenCodeEvent>(
build({
id: `evt_collapse_${eventSequence}`,
created: 1700000002000 + eventSequence,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version },
}),
)
}
function readExpanded(element: Element) {
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test"
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { wire, type Wire } from "@/test-fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
import {
@@ -209,13 +210,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
const content: SessionMessageAssistant["content"] = target
? [
toolContent(
contextTool(
contextIDs[0]!,
assistantID,
"read",
{ path: "src/recent-a.ts", offset: 0, limit: 120 },
status,
),
contextTool(contextIDs[0]!, assistantID, "read", { path: "src/recent-a.ts", offset: 0, limit: 120 }, status),
),
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
toolContent(
@@ -231,7 +226,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
{ type: "text", text: "This assistant text is immediately after the explored context group." },
]
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
return [
return wire<SessionMessageInfo[]>([
{
id: userID,
type: "user",
@@ -249,7 +244,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
finish: "stop",
content,
},
]
])
}
function contextTool(
@@ -314,9 +309,10 @@ let eventSequence = -1
function toolEvents(part: ContextTool): OpenCodeEvent[] {
return [
eventValue(
"session.tool.success",
{
eventValue(2, (envelope) => ({
...envelope,
type: "session.tool.success",
data: {
sessionID,
assistantMessageID: part.messageID,
id: part.callID,
@@ -324,25 +320,30 @@ function toolEvents(part: ContextTool): OpenCodeEvent[] {
metadata: part.state.metadata,
executed: true,
},
2,
),
})),
]
}
function eventValue<Type extends OpenCodeEvent["type"]>(
type: Type,
data: Extract<OpenCodeEvent, { type: Type }>["data"],
version: 1 | 2,
): Extract<OpenCodeEvent, { type: Type }> {
type EventEnvelope<Version extends 1 | 2> = {
id: string
created: number
location: { directory: string }
durable: { aggregateID: string; seq: number; version: Version }
}
function eventValue<Version extends 1 | 2>(
version: Version,
build: (envelope: EventEnvelope<Version>) => Wire<OpenCodeEvent>,
): OpenCodeEvent {
eventSequence++
return {
id: `evt_context_resize_${eventSequence}`,
created: 1700000002000 + eventSequence,
type,
data,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version },
} as unknown as Extract<OpenCodeEvent, { type: Type }>
return wire<OpenCodeEvent>(
build({
id: `evt_context_resize_${eventSequence}`,
created: 1700000002000 + eventSequence,
location: { directory },
durable: { aggregateID: sessionID, seq: eventSequence, version },
}),
)
}
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
@@ -1,36 +1,39 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
OpenCodeEvent,
SessionMessageAssistant,
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import { Session } from "@opencode-ai/schema/session"
import { wire } from "@/test-fixture"
import { event, session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const user = wire<SessionMessageUser>({ id: "msg_user", type: "user", text: "Run it", time: { created: 1 } })
const assistant = (
completed: boolean,
tool = false,
childID?: string,
background = false,
): SessionMessageAssistant => ({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: {
status: "running",
input: { description: "Inspect code", ...(background ? { background: true } : {}) },
metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) },
const assistant = (completed: boolean, tool = false, childID?: string, background = false): SessionMessageAssistant =>
wire<SessionMessageAssistant>({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: {
status: "running",
input: { description: "Inspect code", ...(background ? { background: true } : {}) },
metadata: { status: "running", ...(childID ? { sessionID: childID } : {}) },
},
time: { created: 2 },
},
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
})
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
})
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
@@ -39,7 +42,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
sessionMessages: [
sessionMessages: wire<SessionMessageInfo[]>([
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
assistant(true),
@@ -59,7 +62,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
time: { created: 5 },
},
{ id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } },
],
]),
})
const notices = page.locator('[data-slot="session-timeline-notice"]')
@@ -97,10 +100,10 @@ test("waits for completion before labeling requested background work", async ({
})
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
const childID = Session.ID.make("ses_running_child")
await setupTimeline(page, {
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessions: [session(), session({ id: childID, parentID: Session.ID.make(sessionID), title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
@@ -111,10 +114,10 @@ test("navigates from a running subagent card and hides background controls in th
})
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
const childID = Session.ID.make("ses_background_child")
await setupTimeline(page, {
sessionMessages: [user, assistant(true)],
sessions: [session(), session({ id: childID, parentID: sessionID })],
sessions: [session(), session({ id: childID, parentID: Session.ID.make(sessionID) })],
sessionStatus: { [childID]: { type: "busy" } },
})
@@ -122,10 +125,10 @@ test("shows a badge for active background work", async ({ page }) => {
})
test("separates blocking and already-backgrounded work into two rows", async ({ page }) => {
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const backgroundID = Session.ID.make("ses_background_existing")
const blockingID = Session.ID.make("ses_background_blocking")
const timeline = await setupTimeline(page, {
sessionMessages: [
sessionMessages: wire<SessionMessageInfo[]>([
user,
{
id: "msg_backgrounded",
@@ -180,11 +183,11 @@ test("separates blocking and already-backgrounded work into two rows", async ({
],
time: { created: 4 },
},
],
]),
sessions: [
session(),
session({ id: backgroundID, parentID: sessionID, title: "Background task" }),
session({ id: blockingID, parentID: sessionID, title: "Foreground task" }),
session({ id: backgroundID, parentID: Session.ID.make(sessionID), title: "Background task" }),
session({ id: blockingID, parentID: Session.ID.make(sessionID), title: "Foreground task" }),
],
sessionStatus: {
[sessionID]: { type: "busy" },
@@ -203,12 +206,15 @@ test("separates blocking and already-backgrounded work into two rows", async ({
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
).toHaveAttribute("data-active", "true")
await timeline.transport.send({
id: "evt_background_succeeded",
created: Date.now(),
type: "session.execution.succeeded",
data: { sessionID: backgroundID },
} as never)
await timeline.transport.send(
wire<OpenCodeEvent>({
id: "evt_background_succeeded",
created: Date.now(),
type: "session.execution.succeeded",
data: { sessionID: backgroundID },
durable: { aggregateID: backgroundID, seq: 0, version: 1 },
}),
)
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
await expect(backgroundCard).toContainText("Background task (background)")
})
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/util/encode"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
import { expect, test, type Page } from "@playwright/test"
import { wire, type Wire } from "@/test-fixture"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
@@ -56,14 +57,15 @@ test("shows the not found fallback when the viewed session is deleted", async ({
await openChildFromParent(page)
await expectSessionTitle(page, taskDescription)
events.push({
const deleted: Wire<OpenCodeEvent> = {
id: "evt_session_deleted",
created: 1700000003000,
type: "session.deleted",
durable: { aggregateID: childID, seq: 1, version: 2 },
location: { directory },
data: { sessionID: childID },
})
}
events.push(wire<OpenCodeEvent>(deleted))
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
@@ -148,7 +150,7 @@ function childSession() {
function parentMessages(): SessionMessageInfo[] {
const userID = "msg_user_0001"
const assistantID = "msg_assistant_0001"
return [
return wire<SessionMessageInfo[]>([
{
id: userID,
type: "user",
@@ -179,7 +181,7 @@ function parentMessages(): SessionMessageInfo[] {
},
],
},
]
])
}
async function configurePage(page: Page) {
@@ -1,4 +1,5 @@
import { createTwoFilesPatch } from "diff"
import { wire } from "@/test-fixture"
const words = [
"alpha",
@@ -64,13 +65,13 @@ function id(prefix: string, value: number) {
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
const messageID = id("msg_user", index)
return {
return wire<SessionMessageInfo>({
id: messageID,
type: "user",
time: { created: 1700000000000 + index * 10_000 },
text: lorem(index, textLength),
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
}
})
}
function assistantMessage(
@@ -80,7 +81,7 @@ function assistantMessage(
parts: MessagePart[],
): SessionMessageInfo {
const messageID = id("msg_assistant", index)
return {
return wire<SessionMessageInfo>({
id: messageID,
type: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
@@ -90,7 +91,7 @@ function assistantMessage(
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
finish: "stop",
content: parts.map(messageContent),
}
})
}
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
@@ -140,10 +141,7 @@ function toolPart(
const metadata =
tool === "patch"
? {
files: [
patchFile(index, "modified"),
patchFile(index + 1, index % 2 === 0 ? "added" : "deleted"),
],
files: [patchFile(index, "modified"), patchFile(index + 1, index % 2 === 0 ? "added" : "deleted")],
}
: tool === "edit" || tool === "write"
? { files: [fileDiff(String(input.path ?? `src/generated/file-${index}.ts`), index)] }
@@ -214,14 +212,20 @@ function turn(index: number): SessionMessageInfo[] {
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0
? [toolPart(index, 3, "edit", { path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" }, 700)]
? [
toolPart(
index,
3,
"edit",
{ path: `src/generated/file-${index}.ts`, oldString: "before", newString: "after" },
700,
),
]
: []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { path: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)]
: []),
...(index % 8 === 0 ? [toolPart(index, 8, "patch", { patchText: `Update generated patch ${index}` }, 620)] : []),
...(index % 7 === 0 ? [toolPart(index, 4, "shell", { command: "bun typecheck" }, 620)] : []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
@@ -144,7 +144,7 @@ test.describe("smoke: session timeline", () => {
await expectSessionTitle(page, fixture.expected.targetTitle)
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
const destination = fixture.messages[fixture.targetID].map((message) => String(message.id))
const last = fixture.expected.targetMessageIDs.at(-1)!
await page.evaluate(
({ destination, last }) => {
@@ -284,7 +284,7 @@ test.describe("smoke: session timeline", () => {
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
const last = fixture.expected.targetMessageIDs.at(-1)!
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
const destination = fixture.messages[fixture.targetID].map((message) => String(message.id))
await page.evaluate(
({ destination, last }) => {
const ids = new Set(destination)
+11 -10
View File
@@ -1,10 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { extractPromptComments, extractPromptFromMessage } from "./prompt"
import { wire } from "../test-fixture"
describe("extractPromptFromMessage", () => {
test("restores multiple uploaded attachments", () => {
const message = {
const message = wire<SessionMessageUser>({
id: "msg_1",
type: "user",
text: "check these",
@@ -13,7 +14,7 @@ describe("extractPromptFromMessage", () => {
{ data: "BBB", mime: "application/pdf", source: { type: "inline" }, name: "b.pdf" },
],
time: { created: 1 },
} satisfies SessionMessageUser
})
const result = extractPromptFromMessage(message)
@@ -36,7 +37,7 @@ describe("extractPromptFromMessage", () => {
})
test("restores optimistic data URLs and review comments", () => {
const message = {
const message = wire<SessionMessageUser>({
id: "msg_1",
type: "user",
text: "model text",
@@ -60,7 +61,7 @@ describe("extractPromptFromMessage", () => {
},
],
time: { created: 1 },
} satisfies SessionMessageUser
})
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "visible text" },
@@ -72,7 +73,7 @@ describe("extractPromptFromMessage", () => {
})
test("keeps the directory of a file mention without an at-sign", () => {
const message = {
const message = wire<SessionMessageUser>({
id: "msg_1",
type: "user",
text: "inspect src/client.ts",
@@ -86,7 +87,7 @@ describe("extractPromptFromMessage", () => {
},
],
time: { created: 1 },
} satisfies SessionMessageUser
})
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "inspect " },
@@ -95,25 +96,25 @@ describe("extractPromptFromMessage", () => {
})
test("uses model text when presentation metadata is incomplete", () => {
const message = {
const message = wire<SessionMessageUser>({
id: "msg_1",
type: "user",
text: "model text",
metadata: { displayText: "partial display text" },
time: { created: 1 },
} satisfies SessionMessageUser
})
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
})
test("restores skill mentions as structured Composer parts", () => {
const message = {
const message = wire<SessionMessageUser>({
id: "msg_1",
type: "user",
text: "Use @review",
skills: [{ id: "review", name: "Review", mention: { text: "@review", start: 4, end: 11 } }],
time: { created: 1 },
} satisfies SessionMessageUser
})
expect(extractPromptFromMessage(message)).toMatchObject([
{ type: "text", content: "Use " },
@@ -2,6 +2,8 @@ import { expect, test } from "bun:test"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
import { archiveHomeSession } from "./archive"
import type { ServerConnection } from "@/runtime/server/registry"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { wire } from "@/test-fixture"
const remote = "remote" as ServerConnection.Key
@@ -18,7 +20,7 @@ test("archiving a Home session removes its open titlebar tab", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", location: { directory: "/workspace" } },
session: wire<Pick<SessionInfo, "id" | "location">>({ id: "ses_1", location: { directory: "/workspace" } }),
archive: async () => undefined,
remove: () => {
removed = true
@@ -36,7 +38,7 @@ test("reports archive failures without removing the session", async () => {
await archiveHomeSession({
server: remote,
session: { id: "ses_1", location: { directory: "/workspace" } },
session: wire<Pick<SessionInfo, "id" | "location">>({ id: "ses_1", location: { directory: "/workspace" } }),
archive: async () => Promise.reject(failure),
remove: () => {
removed = true
+8 -5
View File
@@ -1,16 +1,19 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex, parseHomeSessionIndex, retainHomeSessions } from "./index"
import { wire, type Wire } from "@/test-fixture"
const session = (id: string, input: Partial<SessionInfo> = {}) =>
({
const session = (id: string, input: Partial<Wire<SessionInfo>> = {}) =>
wire<SessionInfo>({
id,
projectID: "project",
title: id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
location: { directory: "/repo" },
...input,
}) as SessionInfo
})
describe("Home session index", () => {
test("loads all pages", async () => {
@@ -33,7 +36,7 @@ describe("Home session index", () => {
session("root"),
session("child", { parentID: "root" }),
session("archived", { time: { created: 1, updated: 1, archived: 2 } }),
]).map((item) => item.id),
]).map((item) => String(item.id)),
).toEqual(["root"])
})
@@ -44,6 +47,6 @@ describe("Home session index", () => {
1,
now,
)
expect(result.map((item) => item.id)).toEqual(["b"])
expect(result.map((item) => String(item.id))).toEqual(["b"])
})
})
@@ -2,15 +2,18 @@ import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { LocalProject } from "@/shell/state/layout"
import { buildHomeSessionRecords } from "./records"
import { wire } from "@/test-fixture"
const session = (id: string, directory: string, projectID: string) =>
({
wire<SessionInfo>({
id,
projectID,
title: id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
location: { directory },
time: { created: 1, updated: 1 },
}) as SessionInfo
})
describe("buildHomeSessionRecords", () => {
const opened = { id: "project-a", worktree: "/repo/a", expanded: true } as LocalProject
@@ -23,7 +26,7 @@ describe("buildHomeSessionRecords", () => {
projects: () => [opened],
})
expect(records.map((record) => record.session.id)).toEqual(["a", "b"])
expect(records.map((record) => String(record.session.id))).toEqual(["a", "b"])
expect(records[1]?.project).toMatchObject({ id: "project-b", worktree: "/repo/b", expanded: false })
})
@@ -34,6 +37,6 @@ describe("buildHomeSessionRecords", () => {
projects: () => [opened],
})
expect(records.map((record) => record.session.id)).toEqual(["a"])
expect(records.map((record) => String(record.session.id))).toEqual(["a"])
})
})
@@ -1,15 +1,16 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
import { wire } from "../../test-fixture"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
const plugins = wire<PluginInfo[]>([
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
]
])
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
@@ -52,7 +52,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
.list()
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
const seen = new Set(catalog.map((integration) => String(integration.id)))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
@@ -2,8 +2,9 @@ import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createRoot } from "solid-js"
import { createOpenCodeEventSource } from "./client"
import { wire } from "../../test-fixture"
const permission = {
const permission = wire<Extract<OpenCodeEvent, { type: "permission.asked" }>>({
id: "evt_permission",
created: 1,
type: "permission.asked",
@@ -15,7 +16,7 @@ const permission = {
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
} satisfies Extract<OpenCodeEvent, { type: "permission.asked" }>
})
function setup() {
return createRoot((dispose) => ({ ...createOpenCodeEventSource(), dispose }))
@@ -45,11 +46,11 @@ describe("server event stream", () => {
const other: OpenCodeEvent[] = []
const all: OpenCodeEvent[] = []
let workspaceID: string | undefined
const global = {
const global = wire<Extract<OpenCodeEvent, { type: "server.connected" }>>({
id: "evt_connected",
type: "server.connected",
data: {},
} satisfies Extract<OpenCodeEvent, { type: "server.connected" }>
})
const repoEvents = server.event.location("/repo")
repoEvents.on("permission.asked", (event) => {
@@ -52,7 +52,7 @@ describe("query keys", () => {
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => String(project.id))).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
@@ -80,7 +80,7 @@ describe("query keys", () => {
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
expect(result.map((project) => ({ id: String(project.id), sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
])
@@ -1,22 +1,25 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
import { wire } from "../../../test-fixture"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
const result = normalizeAgentList([
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
color: "primary",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
system: "Build software",
permissions: [{ action: "read", resource: "*", effect: "allow" }],
},
] as AgentListOutput["data"])
const result = normalizeAgentList(
wire<AgentListOutput["data"]>([
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
color: "primary",
model: { id: "gpt-5", providerID: "openai", variant: "high" },
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
system: "Build software",
permissions: [{ action: "read", resource: "*", effect: "allow" }],
},
]),
)
expect(result).toEqual([
{
@@ -41,8 +44,10 @@ describe("normalizeAgentList", () => {
describe("normalizeProviderList", () => {
test("groups current models into the app provider catalog", () => {
const result = normalizeProviderList(
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
[
wire<ProviderListOutput["data"]>([
{ id: "openai", name: "OpenAI", activation: "auto", package: "@ai-sdk/openai" },
]),
wire<ModelListOutput["data"]>([
{
id: "gpt-5",
modelID: "gpt-5",
@@ -69,7 +74,7 @@ describe("normalizeProviderList", () => {
enabled: true,
limit: { context: 1, output: 1 },
},
] as ModelListOutput["data"],
]),
)
expect(result.connected).toEqual(["openai"])
+5 -4
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import { listAllSessions } from "./list"
import { wire } from "../test-fixture"
describe("listAllSessions", () => {
test("loads every page in server order and retains the query", async () => {
@@ -18,7 +19,7 @@ describe("listAllSessions", () => {
const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
expect(result.map((session) => String(session.id))).toEqual(["session-3", "session-2", "session-1"])
expect(result[2]?.time.archived).toBe(2)
expect(calls).toEqual([
{ directory: "/repo", order: "desc", limit: 100, cursor: undefined },
@@ -38,13 +39,13 @@ describe("listAllSessions", () => {
const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
expect(result.map((session) => session.id)).toEqual(["session-1"])
expect(result.map((session) => String(session.id))).toEqual(["session-1"])
expect(cursors).toEqual([undefined, "terminal"])
})
})
function sessionInfo(id: string, archived = false) {
return {
return wire<SessionInfo>({
id,
projectID: "project-1",
agent: "build",
@@ -54,5 +55,5 @@ function sessionInfo(id: string, archived = false) {
time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
title: id,
location: { directory: "/repo" },
} as SessionInfo
})
}
+10 -11
View File
@@ -8,15 +8,12 @@ import {
selectVisibleSessionUserMessages,
} from "./session-domain"
import { createSessionOwnership } from "./session-ownership"
import { wire } from "../test-fixture"
import { SessionMessage } from "@opencode-ai/schema/session-message"
const user = (id: string): SessionMessageUser => ({
id,
type: "user",
text: id,
time: { created: 0 },
})
const user = (id: string) => wire<SessionMessageUser>({ id, type: "user", text: id, time: { created: 0 } })
const assistant: SessionMessageAssistant = {
const assistant = wire<SessionMessageAssistant>({
id: "msg_2",
type: "assistant",
time: { created: 0 },
@@ -25,7 +22,7 @@ const assistant: SessionMessageAssistant = {
content: [],
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
})
describe("session controller invariants", () => {
test("normalizes file tabs once while preserving non-file tabs and order", () => {
@@ -42,9 +39,11 @@ describe("session controller invariants", () => {
const messages: SessionMessageInfo[] = [user("msg_a"), assistant, user("msg_b"), user("msg_c")]
const users = selectSessionUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleSessionUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleSessionUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(users.map((message) => String(message.id))).toEqual(["msg_a", "msg_b", "msg_c"])
expect(
selectVisibleSessionUserMessages(users, SessionMessage.ID.make("msg_b")).map((message) => String(message.id)),
).toEqual(["msg_a"])
expect(selectVisibleSessionUserMessages(users.slice(2), SessionMessage.ID.make("msg_b"))).toEqual([])
expect(selectVisibleSessionUserMessages(users)).toBe(users)
})
@@ -1,27 +1,36 @@
import { describe, expect, test } from "bun:test"
import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
import { sessionPermissionRequest, sessionQuestionForm } from "@/session/requests/session-request-tree"
import { wire } from "@/test-fixture"
const session = (input: { id: string; parentID?: string }) =>
({
wire<SessionInfo>({
id: input.id,
parentID: input.parentID,
}) as SessionInfo
projectID: "project",
title: input.id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
location: { directory: "/repo" },
})
const permission = (id: string, sessionID: string) =>
({
wire<PermissionRequest>({
id,
sessionID,
}) as PermissionRequest
action: "read",
resources: [],
})
const question = (id: string, sessionID: string) =>
({
wire<FormInfo>({
id,
sessionID,
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", type: "string" }],
}) as FormInfo
})
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {
@@ -31,7 +40,7 @@ describe("sessionPermissionRequest", () => {
child: [permission("perm-child", "child")],
}
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-root")
expect(String(sessionPermissionRequest(sessions, permissions, "root")?.id)).toBe("perm-root")
})
test("returns a nested child permission", () => {
@@ -46,7 +55,7 @@ describe("sessionPermissionRequest", () => {
other: [permission("perm-other", "other")],
}
expect(sessionPermissionRequest(sessions, permissions, "root")?.id).toBe("perm-grand")
expect(String(sessionPermissionRequest(sessions, permissions, "root")?.id)).toBe("perm-grand")
})
test("returns undefined without a matching tree permission", () => {
@@ -89,7 +98,7 @@ describe("sessionQuestionForm", () => {
child: [question("q-child", "child")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-root")
expect(String(sessionQuestionForm(sessions, questions, "root")?.id)).toBe("q-root")
})
test("returns a nested child question", () => {
@@ -102,7 +111,7 @@ describe("sessionQuestionForm", () => {
grand: [question("q-grand", "grand")],
}
expect(sessionQuestionForm(sessions, questions, "root")?.id).toBe("q-grand")
expect(String(sessionQuestionForm(sessions, questions, "root")?.id)).toBe("q-grand")
})
test("skips forms that are not questions", () => {
+4 -2
View File
@@ -8,6 +8,8 @@ import {
} from "@/session/composer/session-composer-region"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
import { Form } from "@opencode-ai/schema/form"
import { Session } from "@opencode-ai/schema/session"
import type { SessionDocument } from "@opencode-ai/session-ui/document"
import { CurrentSessionProviders, STORY_MODEL } from "@opencode-ai/session-ui/storybook"
import { SessionTimeline } from "@opencode-ai/session-ui/timeline"
@@ -103,7 +105,7 @@ export function SessionPreview(props: SessionPreviewProps) {
props.request?.type === "question"
? {
type: "question",
value: { ...props.request.value, id: `${props.request.value.id}:${revision}` },
value: { ...props.request.value, id: Form.ID.make(`${props.request.value.id}:${revision}`) },
}
: props.request
}
@@ -213,7 +215,7 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
openParent: () => setState("activity", "Opened the parent Session locally"),
setPromptRef() {},
setDockRef() {},
parentID: () => props.child?.parentID,
parentID: () => (props.child ? Session.ID.make(props.child.parentID) : undefined),
child: () => !!props.child,
showComposer: () => true,
handoffPrompt: () => undefined,
@@ -1,8 +1,10 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { visibleTimelineMessages } from "./controller-projection"
import { wire } from "../../test-fixture"
import { SessionMessage } from "@opencode-ai/schema/session-message"
const messages = [
const messages = wire<SessionMessageInfo[]>([
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
@@ -14,11 +16,11 @@ const messages = [
},
{ id: "msg_3", type: "user", text: "queued", time: { created: 3 } },
{ id: "msg_4", type: "user", text: "reverted", time: { created: 4 } },
] satisfies SessionMessageInfo[]
])
describe("visibleTimelineMessages", () => {
test("hides queued inputs until delivery", () => {
const pending = [
const pending = wire<SessionInboxInfo[]>([
{
id: "msg_3",
sessionID: "ses_1",
@@ -27,17 +29,19 @@ describe("visibleTimelineMessages", () => {
delivery: "queue",
payload: { text: "queued" },
},
] satisfies SessionInboxInfo[]
])
expect(visibleTimelineMessages(messages, pending).map((message) => message.id)).toEqual(["msg_1", "msg_2", "msg_4"])
expect(visibleTimelineMessages(messages, pending).map((message) => String(message.id))).toEqual([
"msg_1",
"msg_2",
"msg_4",
])
})
test("hides the staged revert boundary and later messages", () => {
expect(visibleTimelineMessages(messages, [], "msg_4").map((message) => message.id)).toEqual([
"msg_1",
"msg_2",
"msg_3",
])
expect(visibleTimelineMessages(messages, [], "msg_0")).toEqual([])
expect(
visibleTimelineMessages(messages, [], SessionMessage.ID.make("msg_4")).map((message) => String(message.id)),
).toEqual(["msg_1", "msg_2", "msg_3"])
expect(visibleTimelineMessages(messages, [], SessionMessage.ID.make("msg_0"))).toEqual([])
})
})
+17 -12
View File
@@ -1,25 +1,30 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
import { wire } from "../../test-fixture"
import { SessionMessage } from "@opencode-ai/schema/session-message"
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
const assistant = (id: string): SessionMessageAssistant => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
})
const user = (id: string) => wire<SessionMessageUser>({ id, type: "user", text: id, time: { created: 1 } })
const assistant = (id: string) =>
wire<SessionMessageAssistant>({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
time: { created: 1 },
})
describe("timeline model", () => {
test("selects users and applies the revert boundary", () => {
const messages: SessionMessageInfo[] = [user("msg_a"), assistant("msg_ab"), user("msg_b"), user("msg_c")]
const users = selectUserMessages(messages)
expect(users.map((message) => message.id)).toEqual(["msg_a", "msg_b", "msg_c"])
expect(selectVisibleUserMessages(users, "msg_b").map((message) => message.id)).toEqual(["msg_a"])
expect(selectVisibleUserMessages(users.slice(2), "msg_b")).toEqual([])
expect(users.map((message) => String(message.id))).toEqual(["msg_a", "msg_b", "msg_c"])
expect(
selectVisibleUserMessages(users, SessionMessage.ID.make("msg_b")).map((message) => String(message.id)),
).toEqual(["msg_a"])
expect(selectVisibleUserMessages(users.slice(2), SessionMessage.ID.make("msg_b"))).toEqual([])
expect(selectVisibleUserMessages(users)).toBe(users)
})
@@ -1,21 +1,25 @@
import type { ModelRef, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { reuseTimelineRows, Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { createMemo, type Accessor } from "solid-js"
export { reuseTimelineRows } from "@opencode-ai/session-ui/timeline/projection"
const emptyModel: ModelRef = { id: Model.ID.make(""), providerID: Provider.ID.make("") }
export function createTimelineProjection(input: {
sessionMessages: Accessor<SessionMessageInfo[]>
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
() => new Map<string, SessionMessageInfo>(input.sessionMessages().map((message) => [message.id, message])),
)
const userContextByID = createMemo(() => {
const result = new Map<string, { agent: string; model: ModelRef }>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let model: ModelRef = emptyModel
let userID: string | undefined
input.sessionMessages().forEach((message) => {
if (message.type === "agent-switched") agent = message.agent
@@ -42,9 +46,10 @@ export function createTimelineProjection(input: {
localModelID &&
typeof localModel.providerID === "string"
? {
id: localModelID,
providerID: localModel.providerID,
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
id: Model.ID.make(localModelID),
providerID: Provider.ID.make(localModel.providerID),
variant:
typeof localModel.variant === "string" ? Model.VariantID.make(localModel.variant) : undefined,
}
: model,
})
@@ -23,7 +23,9 @@ export const useSessionHashScroll = (input: {
consumePendingMessage: (key: string) => string | undefined
}) => {
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
const messageById = createMemo(
() => new Map<string, SessionMessageUser>(visibleUserMessages().map((message) => [message.id, message])),
)
let pendingKey = ""
let clearing = false
+10 -10
View File
@@ -17,11 +17,12 @@ import {
} from "./helpers"
import { pathKey } from "@/workspaces/path-key"
import { ServerConnection } from "@/runtime/server/registry"
import { wire, type Wire } from "@/test-fixture"
const serverKey = ServerConnection.Key.make
const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { directory: string }) =>
({
const session = (input: Partial<Wire<SessionInfo>> & Pick<Wire<SessionInfo>, "id"> & { directory: string }) =>
wire<SessionInfo>({
projectID: "project",
title: "",
cost: 0,
@@ -30,8 +31,7 @@ const session = (input: Partial<SessionInfo> & Pick<SessionInfo, "id"> & { direc
time: { created: 0, updated: 0, archived: undefined },
...input,
location: { directory: input.directory },
directory: undefined,
}) as SessionInfo
})
describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => {
@@ -73,7 +73,7 @@ describe("layout workspace helpers", () => {
120_000,
)
expect(result?.id).toBe("workspace")
expect(String(result?.id)).toBe("workspace")
})
test("sorts recent sessions by persisted update time instead of id", () => {
@@ -88,7 +88,7 @@ describe("layout workspace helpers", () => {
3,
)
expect(result.map((item) => item.id)).toEqual(["ses_a", "ses_z"])
expect(result.map((item) => String(item.id))).toEqual(["ses_a", "ses_z"])
})
test("uses id only to break equal session timestamps", () => {
@@ -97,7 +97,7 @@ describe("layout workspace helpers", () => {
session({ id: "ses_a", directory: "/workspace", time: { created: 1, updated: 2, archived: undefined } }),
]
expect(sessions.sort(compareSessionTime).map((item) => item.id)).toEqual(["ses_a", "ses_z"])
expect(sessions.sort(compareSessionTime).map((item) => String(item.id))).toEqual(["ses_a", "ses_z"])
})
test("detects project permissions with a filter", () => {
@@ -151,7 +151,7 @@ describe("layout workspace helpers", () => {
120_000,
)
expect(result?.id).toBe("root")
expect(String(result?.id)).toBe("root")
})
test("finds the direct child on the active session path", () => {
@@ -161,8 +161,8 @@ describe("layout workspace helpers", () => {
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
]
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
expect(String(childSessionOnPath(list, "root", "leaf")?.id)).toBe("child")
expect(String(childSessionOnPath(list, "child", "leaf")?.id)).toBe("leaf")
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
})
+1 -1
View File
@@ -35,7 +35,7 @@ export function hasProjectPermissions<T>(
export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID: string, activeID?: string) => {
if (!activeID || activeID === rootID) return
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
const map = new Map<string, SessionInfo>((sessions ?? []).map((session) => [session.id, session]))
let id = activeID
while (id) {
+14
View File
@@ -0,0 +1,14 @@
import type { Brand } from "effect"
export type Wire<T> =
T extends Brand.Brand<string>
? Brand.Brand.Unbranded<T>
: T extends ReadonlyArray<infer Item>
? Wire<Item>[]
: T extends object
? { [Key in keyof T]: Wire<T[Key]> }
: T
export function wire<T>(value: Wire<T>): T {
return value as T
}
@@ -1,15 +1,17 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { invalidateFromWatcher } from "./watcher"
import { wire } from "../../test-fixture"
type FilesystemEvent = Extract<OpenCodeEvent, { type: "filesystem.changed" }>
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]): FilesystemEvent => ({
id: `evt_${file}`,
created: 1,
type: "filesystem.changed",
data: { file, event },
})
const filesystemEvent = (file: string, event: FilesystemEvent["data"]["event"]) =>
wire<FilesystemEvent>({
id: `evt_${file}`,
created: 1,
type: "filesystem.changed",
data: { file, event },
})
describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => {
+12 -5
View File
@@ -1,5 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise"
import type { Workspace } from "@opencode-ai/schema/workspace"
import { type Accessor, createEffect, createMemo, createSignal } from "solid-js"
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
@@ -13,13 +14,19 @@ export type WorkspaceLocation = LocationContext & {
const context = createSimpleContext({
name: "Location",
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
init: (props: {
directory: string | Accessor<string>
workspaceID?: Workspace.ID | Accessor<Workspace.ID | undefined>
}) => {
const serverSDK = useServerSDK()
const data = useData()
const ref = createMemo(() => ({
directory: typeof props.directory === "function" ? props.directory() : props.directory,
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
}))
const ref = createMemo<LocationRef>(() => {
const workspaceID = typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID
return {
directory: typeof props.directory === "function" ? props.directory() : props.directory,
workspaceID,
}
})
const current = createMemo(() => data.location.info(ref()))
const [error, setError] = createSignal<{ readonly location: LocationRef; readonly cause: unknown }>()
let generation = 0
+1 -1
View File
@@ -120,7 +120,7 @@ test("groups nested non-archived workspace sessions by latest activity", () => {
],
"/workspace",
)
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
expect(sessions.map((item) => String(item.id))).toEqual(["nested", "old"])
})
test("merges workspace placement by freshness with authoritative server ties", () => {
+7 -2
View File
@@ -8,6 +8,7 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
@@ -286,7 +287,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
state.model = { ...state.model, variant: Model.VariantID.make(params.value) }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
break
}
@@ -456,7 +457,11 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return { providerID: model.providerID, id: model.id, variant: selected.variant }
return {
providerID: model.providerID,
id: model.id,
variant: selected.variant === undefined ? undefined : Model.VariantID.make(selected.variant),
}
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
+7 -1
View File
@@ -1,5 +1,7 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
import { setTimeout } from "node:timers/promises"
import { readStdin } from "./util/io"
@@ -94,7 +96,11 @@ export async function runMini(input: MiniCommandInput) {
agent: next.agent,
environment,
model: next.model
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
? {
providerID: Provider.ID.make(next.model.providerID),
id: Model.ID.make(next.model.modelID),
variant: next.variant === undefined ? undefined : Model.VariantID.make(next.variant),
}
: undefined,
prepare,
signal,
+7 -1
View File
@@ -1,5 +1,6 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { open } from "node:fs/promises"
import path from "node:path"
@@ -112,7 +113,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
? {
providerID: selected.providerID,
id: selected.id,
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
variant:
options.variant !== undefined
? Model.VariantID.make(options.variant)
: "variant" in selected
? selected.variant
: undefined,
}
: undefined
if ((options.variant ?? explicit?.variant) && !model)
+293 -208
View File
@@ -3,6 +3,7 @@ import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { resolve } from "node:path"
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
import { wire } from "../fixture/wire"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
@@ -15,51 +16,66 @@ describe("acp event behavior", () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_before",
ordinal: 0,
delta: "before admission",
}),
ephemeralEvent(
"session.text.delta",
wire({
sessionID: "ses_a",
assistantMessageID: "msg_before",
ordinal: 0,
delta: "before admission",
}),
),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_b", inboxID: id })))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_a", inboxID: "input_other" })))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_wrong_input",
ordinal: 0,
delta: "wrong input",
}),
ephemeralEvent(
"session.text.delta",
wire({
sessionID: "ses_a",
assistantMessageID: "msg_wrong_input",
ordinal: 0,
delta: "wrong input",
}),
),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_a", inboxID: id })))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
assistantMessageID: "msg_b",
ordinal: 0,
delta: "other session",
}),
ephemeralEvent(
"session.text.delta",
wire({
sessionID: "ses_b",
assistantMessageID: "msg_b",
ordinal: 0,
delta: "other session",
}),
),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
ordinal: 0,
delta: "accepted",
}),
ephemeralEvent(
"session.text.delta",
wire({
sessionID: "ses_a",
assistantMessageID: "msg_a",
ordinal: 0,
delta: "accepted",
}),
),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_a",
assistantMessageID: "msg_a",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
durableEvent(
"session.step.ended",
wire({
sessionID: "ses_a",
assistantMessageID: "msg_a",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_b" })))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_a" })))
},
})
@@ -99,41 +115,53 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
async onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_order", inboxID: id })))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: "think-1",
}),
ephemeralEvent(
"session.reasoning.delta",
wire({
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: "think-1",
}),
),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
delta: "answer",
}),
ephemeralEvent(
"session.text.delta",
wire({
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
delta: "answer",
}),
),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 2,
delta: "think-2",
}),
ephemeralEvent(
"session.reasoning.delta",
wire({
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 2,
delta: "think-2",
}),
),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
durableEvent(
"session.step.ended",
wire({
sessionID: "ses_order",
assistantMessageID: "msg_order",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_order" })))
await releaseSubmit.promise
},
})
@@ -195,43 +223,55 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_parent", inboxID: id })))
send(
durableEvent("session.created", {
sessionID: "ses_child",
...childSession("ses_child", "ses_parent", "Explore code"),
}),
durableEvent(
"session.created",
wire({
sessionID: "ses_child",
...childSession("ses_child", "ses_parent", "Explore code"),
}),
),
)
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
send(durableEvent("session.execution.started", wire({ sessionID: "ses_child" })))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
name: "read",
}),
durableEvent(
"session.tool.input.started",
wire({
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
name: "read",
}),
),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
input: { path: "/workspace/src/index.ts" },
executed: false,
}),
durableEvent(
"session.tool.called",
wire({
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
input: { path: "/workspace/src/index.ts" },
executed: false,
}),
),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
metadata: {},
content: [{ type: "text", text: "source" }],
executed: true,
}),
durableEvent(
"session.tool.success",
wire({
sessionID: "ses_child",
assistantMessageID: "msg_child",
id: "call_read",
metadata: {},
content: [{ type: "text", text: "source" }],
executed: true,
}),
),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_child" })))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_parent" })))
},
})
@@ -276,14 +316,17 @@ describe("acp event behavior", () => {
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_parent", inboxID: id })))
send(
durableEvent("session.created", {
sessionID: "ses_background",
...childSession("ses_background", "ses_parent", "Background research"),
}),
durableEvent(
"session.created",
wire({
sessionID: "ses_background",
...childSession("ses_background", "ses_parent", "Background research"),
}),
),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_parent" })))
},
})
@@ -301,41 +344,53 @@ describe("acp event behavior", () => {
expect(response.stopReason).toBe("end_turn")
fixture.send(
durableEvent("session.created", {
sessionID: "ses_future",
...childSession("ses_future", "ses_parent", "Later turn child"),
}),
durableEvent(
"session.created",
wire({
sessionID: "ses_future",
...childSession("ses_future", "ses_parent", "Later turn child"),
}),
),
)
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
fixture.send(durableEvent("session.execution.started", wire({ sessionID: "ses_future" })))
fixture.send(durableEvent("session.execution.started", wire({ sessionID: "ses_background" })))
fixture.send(
durableEvent("session.tool.input.started", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
name: "shell",
}),
durableEvent(
"session.tool.input.started",
wire({
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
name: "shell",
}),
),
)
fixture.send(
durableEvent("session.tool.called", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
input: { command: "pwd" },
executed: false,
}),
durableEvent(
"session.tool.called",
wire({
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
input: { command: "pwd" },
executed: false,
}),
),
)
fixture.send(
durableEvent("session.tool.success", {
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
metadata: { exit: 0 },
content: [{ type: "text", text: "/workspace" }],
executed: true,
}),
durableEvent(
"session.tool.success",
wire({
sessionID: "ses_background",
assistantMessageID: "msg_background",
id: "call_shell",
metadata: { exit: 0 },
content: [{ type: "text", text: "/workspace" }],
executed: true,
}),
),
)
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
fixture.send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_background" })))
await withTimeout(completed.promise, "background child completion was not delivered")
expect(updates).toEqual([])
@@ -370,88 +425,115 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_tools", inboxID: id })))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
name: "shell",
}),
durableEvent(
"session.tool.input.started",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
name: "shell",
}),
),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
durableEvent(
"session.tool.called",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
input: { command: "printf done", workdir: "sub" },
executed: false,
}),
),
)
send(
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
metadata: { phase: 1 },
}),
ephemeralEvent(
"session.tool.progress",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
metadata: { phase: 1 },
}),
),
)
send(
durableEvent("session.tool.success", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
executed: true,
}),
durableEvent(
"session.tool.success",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_ok",
metadata: { exit: 0 },
content: [{ type: "text", text: "done" }],
executed: true,
}),
),
)
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
name: "read",
}),
durableEvent(
"session.tool.input.started",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
name: "read",
}),
),
)
send(
durableEvent("session.tool.called", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
input: { path: "/workspace/missing.ts" },
executed: false,
}),
durableEvent(
"session.tool.called",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
input: { path: "/workspace/missing.ts" },
executed: false,
}),
),
)
send(
ephemeralEvent("session.tool.progress", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
metadata: { bytes: 0 },
}),
ephemeralEvent(
"session.tool.progress",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
metadata: { bytes: 0 },
}),
),
)
send(
durableEvent("session.tool.failed", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
durableEvent(
"session.tool.failed",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
id: "call_fail",
error: { type: "tool.error", message: "not found" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "opening" }],
executed: true,
}),
),
)
send(
durableEvent("session.step.ended", {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
durableEvent(
"session.step.ended",
wire({
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
finish: "stop",
cost: 0,
tokens: tokens(),
}),
),
)
send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
send(durableEvent("session.execution.succeeded", wire({ sessionID: "ses_tools" })))
},
})
@@ -597,10 +679,10 @@ describe("acp event behavior", () => {
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_cancel", inboxID: id })))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
send(durableEvent("session.execution.interrupted", wire({ sessionID, reason: "user" })))
},
})
const result = streamTurn({
@@ -680,22 +762,25 @@ describe("acp event behavior", () => {
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
send(durableEvent("session.inbox.delivered", wire({ sessionID: "ses_form", inboxID: id })))
send(
ephemeralEvent("form.created", {
form: {
id: "frm_question",
sessionID: "ses_form",
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Choice", type: "string" }],
},
}),
ephemeralEvent(
"form.created",
wire({
form: {
id: "frm_question",
sessionID: "ses_form",
title: "Questions",
metadata: { kind: "question" },
fields: [{ key: "q0", title: "Choice", type: "string" }],
},
}),
),
)
},
onFormCancel({ sessionID, formID, send }) {
send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
send(durableEvent("session.execution.succeeded", { sessionID }))
send(ephemeralEvent("form.cancelled", wire({ sessionID, id: formID })))
send(durableEvent("session.execution.succeeded", wire({ sessionID })))
},
})
@@ -763,7 +848,7 @@ function tokens() {
}
function replayFixtureMessages(): SessionMessageInfo[] {
return [
return wire<SessionMessageInfo[]>([
{
id: "msg_user",
type: "user",
@@ -841,11 +926,11 @@ function replayFixtureMessages(): SessionMessageInfo[] {
},
],
},
]
])
}
function replayToolMessage(id: string) {
return {
return wire<SessionMessageInfo>({
id: `msg_${id}`,
type: "assistant",
agent: "build",
@@ -865,5 +950,5 @@ function replayToolMessage(id: string) {
},
},
],
} satisfies SessionMessageInfo
})
}
+16 -15
View File
@@ -10,6 +10,7 @@ import {
type TokenUsageInfo,
} from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
import { wire, type Wire } from "../fixture/wire"
export type FixtureRequest = {
readonly method: string
@@ -37,7 +38,7 @@ type FixtureOptions = {
readonly skills?: readonly SkillInfo[]
}
export const testModel = {
export const testModel = wire<ModelInfo>({
id: "test-model",
modelID: "test-model",
providerID: "test",
@@ -49,9 +50,9 @@ export const testModel = {
status: "active",
enabled: true,
limit: { context: 100_000, output: 10_000 },
} satisfies ModelInfo
})
export const secondModel = {
export const secondModel = wire<ModelInfo>({
id: "second-model",
modelID: "second-model",
providerID: "test",
@@ -63,18 +64,18 @@ export const secondModel = {
status: "active",
enabled: true,
limit: { context: 200_000, output: 20_000 },
} satisfies ModelInfo
})
export const buildAgent = {
export const buildAgent = wire<AgentInfo>({
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
})
export const planAgent = {
export const planAgent = wire<AgentInfo>({
id: "plan",
name: "Plan",
description: "Plan first",
@@ -82,36 +83,36 @@ export const planAgent = {
mode: "primary",
hidden: false,
permissions: [],
} satisfies AgentInfo
})
export const reviewCommand = {
export const reviewCommand = wire<CommandInfo>({
name: "review",
description: "Review changes",
template: "",
} satisfies CommandInfo
})
export const verifySkill = {
export const verifySkill = wire<SkillInfo>({
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
} satisfies SkillInfo
})
export function makeSession(
id: string,
input: {
readonly cwd?: string
readonly agent?: string
readonly model?: ModelRef
readonly model?: Wire<ModelRef>
readonly cost?: number
readonly tokens?: TokenUsageInfo
readonly time?: SessionInfo["time"]
readonly title?: string
} = {},
): SessionInfo {
return {
return wire<SessionInfo>({
id,
projectID: "global",
agent: input.agent ?? "build",
@@ -121,7 +122,7 @@ export function makeSession(
time: input.time ?? { created: 0, updated: 0 },
title: input.title ?? `Session ${id}`,
location: { directory: input.cwd ?? "/workspace" },
}
})
}
export function makeACPFixture(options: FixtureOptions = {}) {
@@ -1,10 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
import type { ModelInfo } from "@opencode-ai/client/promise"
import { wire } from "../fixture/wire"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
describe("acp service lifecycle", () => {
test("does not persist the first catalog variant when no explicit default exists", async () => {
const model = { ...secondModel, variants: [{ id: "none" }, { id: "high" }] }
const model = wire<ModelInfo>({ ...secondModel, variants: [{ id: "none" }, { id: "high" }] })
await using fixture = makeACPFixture({
models: [model],
defaultModel: model,
+12 -11
View File
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode-ai/client/promise"
import { OpenCode, type AgentInfo, type ModelInfo, type SessionInfo, type SkillInfo } from "@opencode-ai/client/promise"
import { ACPService } from "../../src/acp/service"
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
import { wire } from "../fixture/wire"
describe("acp service", () => {
test("creates a v2 session, registers mcp, and publishes commands", async () => {
@@ -72,7 +73,7 @@ describe("acp service", () => {
})
})
const model = {
const model = wire<ModelInfo>({
id: "test-model",
modelID: "test-model",
providerID: "test",
@@ -81,30 +82,30 @@ const model = {
variants: [{ id: "default" }, { id: "high" }],
time: { released: 0 },
cost: [],
status: "active" as const,
status: "active",
enabled: true,
limit: { context: 100_000, output: 10_000 },
}
})
const agent = {
const agent = wire<AgentInfo>({
id: "build",
name: "Build",
request: { settings: {}, headers: {}, body: {} },
mode: "primary" as const,
mode: "primary",
hidden: false,
permissions: [],
}
})
const skill = {
const skill = wire<SkillInfo>({
id: "verify",
name: "verify",
description: "Verify work",
slash: true,
location: "/skills/verify.md",
content: "verify",
}
})
const session = {
const session = wire<SessionInfo>({
id: "ses_acp",
projectID: "global",
agent: "build",
@@ -114,4 +115,4 @@ const session = {
time: { created: 0, updated: 0 },
title: "New session",
location: { directory: "/workspace" },
}
})
+5 -4
View File
@@ -1,4 +1,5 @@
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode-ai/client/promise"
import { wire, type Wire } from "../fixture/wire"
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
@@ -39,7 +40,7 @@ const ids = { next: 0 }
export function durableEvent<Type extends DurableEvent["type"]>(
type: Type,
data: Extract<DurableEvent, { type: Type }>["data"],
data: Extract<DurableEvent, { type: Type }>["data"] | Wire<Extract<DurableEvent, { type: Type }>["data"]>,
) {
ids.next++
return {
@@ -53,7 +54,7 @@ export function durableEvent<Type extends DurableEvent["type"]>(
export function ephemeralEvent<Type extends EphemeralEvent["type"]>(
type: Type,
data: Extract<EphemeralEvent, { type: Type }>["data"],
data: Extract<EphemeralEvent, { type: Type }>["data"] | Wire<Extract<EphemeralEvent, { type: Type }>["data"]>,
) {
ids.next++
return { id: `evt_${ids.next}`, created: ids.next, type, data }
@@ -194,7 +195,7 @@ function stringField(value: unknown, key: string) {
}
function assistantMessage(id: string) {
return {
return wire<SessionMessageInfo>({
id,
type: "assistant",
agent: "build",
@@ -203,5 +204,5 @@ function assistantMessage(id: string) {
finish: "stop",
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
} satisfies SessionMessageInfo
})
}
+14
View File
@@ -0,0 +1,14 @@
import type { Brand } from "effect"
export type Wire<T> =
T extends Brand.Brand<string>
? Brand.Brand.Unbranded<T>
: T extends ReadonlyArray<infer Item>
? Wire<Item>[]
: T extends object
? { [Key in keyof T]: Wire<T[Key]> }
: T
export function wire<T>(value: Wire<T>): T {
return value as T
}
+9 -6
View File
@@ -1,10 +1,11 @@
import { describe, expect, test } from "bun:test"
import { ClientError, OpenCode } from "@opencode-ai/client/promise"
import { ClientError, OpenCode, type ModelRef } from "@opencode-ai/client/promise"
import { OPENCODE_VERSION } from "../src/version"
import path from "node:path"
import { createMiniConnection, mergeInput as mergeInteractiveInput, resolveMiniTarget } from "../src/mini"
import { mergeInput as mergeNonInteractiveInput, parseRunModel } from "../src/run/run"
import { parseSessionTargetModel } from "../src/session-target"
import { wire } from "./fixture/wire"
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -106,11 +107,13 @@ describe("mini command", () => {
expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe(
JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }),
)
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual({
providerID: "openrouter",
id: "openai/gpt-5",
variant: "high",
})
expect(parseSessionTargetModel("openrouter/openai/gpt-5#high")).toEqual(
wire<ModelRef>({
providerID: "openrouter",
id: "openai/gpt-5",
variant: "high",
}),
)
})
test("is registered in the preview CLI", async () => {
+10 -3
View File
@@ -1,11 +1,12 @@
import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
import { wire } from "./fixture/wire"
test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
wire<Parameters<typeof format>[0]>([
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{
id: "acme.dual",
@@ -19,7 +20,7 @@ test("formats server and TUI plugins in sections without builtins", () => {
error: "broken",
tui: false,
},
],
]),
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
@@ -41,6 +42,12 @@ test("formats server and TUI plugins in sections without builtins", () => {
test("includes builtins when requested", () => {
expect(
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
format(
wire<Parameters<typeof format>[0]>([
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
]),
[],
true,
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
+46 -27
View File
@@ -2,62 +2,70 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import {
OpenCode,
type EventSubscribeOutput,
type LocationRef,
type SessionMessageAssistantTool,
type SessionMessageInfo,
} from "@opencode-ai/client/promise"
import { runNonInteractivePrompt } from "../../src/run/noninteractive"
import { type Wire, wire } from "../fixture/wire"
type V2Event = EventSubscribeOutput
type FormInfo = Extract<V2Event, { type: "form.created" }>["data"]["form"]
const location = { directory: "/work tree", workspaceID: "wrk_1" }
const location = wire<LocationRef>({ directory: "/work tree", workspaceID: "wrk_1" })
function ok<T>(data: T) {
return Promise.resolve(data)
}
function form(id: string, sessionID: string): FormInfo {
return {
return wire<FormInfo>({
id,
sessionID,
title: "Input requested",
fields: [{ key: "authorization", type: "external", url: "https://example.com/form" }],
}
})
}
function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
return wire<V2Event>({
id: `evt_${info.id}`,
created: 0,
type: "form.created",
location: eventLocation,
data: { form: info },
})
}
function prompted(inboxID: string): V2Event {
return {
return wire<V2Event>({
id: "evt_prompted",
created: 0,
type: "session.inbox.delivered",
durable: { aggregateID: "ses_1", seq: 0, version: 1 },
data: { sessionID: "ses_1", inboxID },
}
})
}
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
if (outcome === "interrupted")
return {
return wire<V2Event>({
id: "evt_interrupted",
created: 0,
type: "session.execution.interrupted",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: { sessionID: "ses_1", reason: "user" },
}
return {
})
return wire<V2Event>({
id: "evt_succeeded",
created: 0,
type: "session.execution.succeeded",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: { sessionID: "ses_1" },
}
})
}
function stepStarted(): V2Event {
return {
return wire<V2Event>({
id: "evt_step_started",
created: 1,
type: "session.step.started",
@@ -68,11 +76,11 @@ function stepStarted(): V2Event {
agent: "build",
model: { providerID: "test", id: "test-model" },
},
}
})
}
function stepFailed(message: string): V2Event {
return {
return wire<V2Event>({
id: "evt_step_failed",
created: 2,
type: "session.step.failed",
@@ -82,11 +90,11 @@ function stepFailed(message: string): V2Event {
assistantMessageID: "msg_assistant",
error: { type: "provider.transport", message },
},
}
})
}
function executionFailed(message: string): V2Event {
return {
return wire<V2Event>({
id: "evt_execution_failed",
created: 3,
type: "session.execution.failed",
@@ -95,11 +103,11 @@ function executionFailed(message: string): V2Event {
sessionID: "ses_1",
error: { type: "provider.transport", message },
},
}
})
}
function failedTool(inboxID: string): V2Event[] {
return [
return wire<V2Event[]>([
prompted(inboxID),
{
id: "evt_failed_tool_input",
@@ -153,12 +161,12 @@ function failedTool(inboxID: string): V2Event[] {
},
},
settled(),
]
])
}
function successfulGrep(inboxID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
return wire<V2Event[]>([
prompted(inboxID),
{
id: "evt_grep_input",
@@ -200,7 +208,7 @@ function successfulGrep(inboxID: string): V2Event[] {
},
},
settled(),
]
])
}
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
@@ -214,12 +222,12 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
messages?: (inboxID: string) => Wire<SessionMessageInfo[]>
wait?: () => Promise<void>
terminalDelay?: number
}) {
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
const values: V2Event[] = [{ id: "evt_connected", type: "server.connected", data: {} }]
const values: V2Event[] = [wire<V2Event>({ id: "evt_connected", type: "server.connected", data: {} })]
let wake: (() => void) | undefined
const wait = Promise.withResolvers<void>()
const stream = (async function* (): AsyncGenerator<V2Event, void, unknown> {
@@ -254,10 +262,12 @@ async function run(input: {
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
ok({
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
cursor: {},
}),
ok(
wire<Awaited<ReturnType<typeof sdk.message.list>>>({
data: input.messages?.(promptID) ?? [{ id: promptID, type: "user", text: "hello", time: { created: 1 } }],
cursor: {},
}),
),
)
spyOn(sdk.session, "prompt").mockImplementation((request) => {
const messageID = request.id ?? "msg_prompt"
@@ -265,7 +275,16 @@ async function run(input: {
values.push(...input.turn(messageID))
wake?.()
wake = undefined
return ok({ id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
return ok(
wire<Awaited<ReturnType<typeof sdk.session.prompt>>>({
id: messageID,
sessionID: "ses_1",
timeCreated: 1,
type: "user",
payload: { text: "hello" },
delivery: "steer",
}),
)
})
await runNonInteractivePrompt({
client: sdk,
+14 -7
View File
@@ -1,13 +1,18 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode, type LocationGetOutput, type ModelRef, type SessionInfo } from "@opencode-ai/client/promise"
import { resolveSessionTarget, SessionTargetMutationError } from "../src/session-target"
import { type Wire, wire } from "./fixture/wire"
function location(directory: string, workspaceID?: string): LocationGetOutput {
return { directory, workspaceID, project: { id: "project", directory, canonical: directory } }
return wire<LocationGetOutput>({
directory,
workspaceID,
project: { id: "project", directory, canonical: directory },
})
}
function session(id: string, directory: string, workspaceID?: string, model?: ModelRef): SessionInfo {
return {
function session(id: string, directory: string, workspaceID?: string, model?: Wire<ModelRef>): SessionInfo {
return wire<SessionInfo>({
id,
projectID: "project",
title: id,
@@ -16,7 +21,7 @@ function session(id: string, directory: string, workspaceID?: string, model?: Mo
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
})
}
const prepare = async (input: { model: ModelRef | undefined; agent: string | undefined }) => ({
@@ -52,7 +57,7 @@ describe("session target resolver", () => {
const target = await resolveSessionTarget({ client, location: { directory: "/project" }, continue: true, prepare })
expect(list).toHaveBeenCalledTimes(2)
expect(target.session.id).toBe("ses_implicit")
expect(String(target.session.id)).toBe("ses_implicit")
})
test("attaches the terminal environment to the resolved local Session", async () => {
@@ -90,7 +95,7 @@ describe("session target resolver", () => {
agent: "requested",
prepare: async (input) => {
order.push("prepare")
expect(input.location.workspaceID).toBe("work_1")
expect(String(input.location.workspaceID)).toBe("work_1")
return { model: input.model, agent: "prepared" }
},
})
@@ -101,7 +106,9 @@ describe("session target resolver", () => {
test("uses the agent resolved by the server for a fresh Session", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
spyOn(client.session, "create").mockResolvedValue({ ...session("ses_fresh", "/project"), agent: "review" })
spyOn(client.session, "create").mockResolvedValue(
wire<SessionInfo>({ ...session("ses_fresh", "/project"), agent: "review" }),
)
const target = await resolveSessionTarget({ client, prepare })
expect(target.agent).toBe("review")
+22 -4
View File
@@ -33,6 +33,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Snapshot } from "@opencode-ai/schema/snapshot"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Workspace } from "@opencode-ai/schema/workspace"
@@ -83,12 +84,28 @@ const effectTypeReferences = [
typeReference("PositiveInt", "@opencode-ai/schema/schema", PositiveInt),
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
]
const promiseTypeReferences = [
...effectTypeReferences.filter(
(reference) =>
reference.name.endsWith("ID") && !reference.name.startsWith("Project.") && !reference.name.startsWith("Pty."),
),
...namespaceTypes("ProjectSchema", "@opencode-ai/schema/project", Project, "Project").filter((reference) =>
reference.name.endsWith("ID"),
),
...namespaceTypes("PtySchema", "@opencode-ai/schema/pty", Pty, "Pty").filter((reference) =>
reference.name.endsWith("ID"),
),
...namespaceTypes("Snapshot", "@opencode-ai/schema/snapshot", Snapshot).filter((reference) =>
reference.name.endsWith("ID"),
),
]
await Effect.runPromise(
Effect.all(
[
write(
emitPromise(promiseContract, {
typeReferences: promiseTypeReferences,
mutableOutputs: true,
}),
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
@@ -118,16 +135,17 @@ await Effect.runPromise(
).pipe(Effect.provide(NodeFileSystem.layer)),
)
function namespaceTypes(namespace: string, module: string, values: object) {
function namespaceTypes(namespace: string, module: string, values: object, importedName = namespace) {
return Object.entries(values).flatMap(([name, schema]) =>
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema)] : [],
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema, importedName)] : [],
)
}
function typeReference(name: string, module: string, schema: Schema.Top) {
function typeReference(name: string, module: string, schema: Schema.Top, importedName = name.split(".")[0]) {
const namespace = name.split(".")[0]
return {
schema,
name,
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
import: `import type { ${importedName === namespace ? importedName : `${importedName} as ${namespace}`} } from ${JSON.stringify(module)}`,
}
}
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -34,6 +34,8 @@ import type {
OpenCodeClient,
WebSearchProvider,
} from "../promise"
import { Agent } from "@opencode-ai/schema/agent"
import { ProjectID } from "@opencode-ai/schema/project-id"
import { Worktree } from "@opencode-ai/schema/worktree"
import { SessionID } from "@opencode-ai/schema/session-id"
import { SessionMessage } from "@opencode-ai/schema/session-message"
@@ -59,8 +61,6 @@ export type CreateDataInput = {
}
}
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
// until MCP elicitations carry session ownership.
@@ -490,7 +490,7 @@ export function createData(config: CreateDataInput) {
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
@@ -505,14 +505,14 @@ export function createData(config: CreateDataInput) {
if (!store.session.message[event.data.sessionID]) return
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
})
})
void api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.session.message({ sessionID: event.data.sessionID, messageID: SessionMessage.ID.fromEvent(event.id) })
.then((item) => {
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id)
@@ -542,7 +542,7 @@ export function createData(config: CreateDataInput) {
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
@@ -602,7 +602,7 @@ export function createData(config: CreateDataInput) {
if (updateText === undefined) return
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
@@ -614,7 +614,7 @@ export function createData(config: CreateDataInput) {
case "session.synthetic":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
@@ -626,7 +626,7 @@ export function createData(config: CreateDataInput) {
case "session.shell.started":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
@@ -864,7 +864,7 @@ export function createData(config: CreateDataInput) {
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
@@ -927,7 +927,7 @@ export function createData(config: CreateDataInput) {
return
}
message.append(draft, index, {
id: messageIDFromEvent(event.id),
id: SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "completed",
reason: event.data.reason,
@@ -943,7 +943,7 @@ export function createData(config: CreateDataInput) {
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
const current = draft[position]
const failed: Extract<SessionMessageInfo, { type: "compaction"; status: "failed" }> = {
id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id),
id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "failed",
reason: event.data.reason ?? "manual",
@@ -1164,9 +1164,9 @@ export function createData(config: CreateDataInput) {
const now = Date.now()
sessionOutbox.add(id)
result.session.remember({
id,
projectID: projectID ?? store.location[locationKey(location)]?.info?.project.id ?? "",
agent: payload.agent,
id: SessionID.make(id),
projectID: ProjectID.make(projectID ?? store.location[locationKey(location)]?.info?.project.id ?? ""),
agent: payload.agent === undefined ? undefined : Agent.ID.make(payload.agent),
model: payload.model,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -1214,8 +1214,8 @@ export function createData(config: CreateDataInput) {
if (fresh) {
outbox.add(id)
admitLocal({
id,
sessionID: request.sessionID,
id: SessionMessage.ID.make(id),
sessionID: SessionID.make(request.sessionID),
timeCreated: Date.now(),
type: "user",
delivery: request.delivery ?? "steer",
@@ -24,6 +24,23 @@ test("generated Effect API names canonical and composed outputs", async () => {
expect(source).not.toContain("HttpApiClient.ForApi")
})
test("generated Promise outputs preserve canonical ID brands", async () => {
const source = await Bun.file(new URL("../src/promise/generated/types.ts", import.meta.url)).text()
expect(source).toContain('import type { Project as ProjectSchema } from "@opencode-ai/schema/project"')
expect(source).toContain('import type { Pty as PtySchema } from "@opencode-ai/schema/pty"')
expect(source).toContain("export type SessionInfo = {")
expect(source).toContain("id: Session.ID")
expect(source).toContain("export type Project = {")
expect(source).toContain("id: ProjectSchema.ID")
expect(source).toContain("export type Pty = {")
expect(source).toContain("id: PtySchema.ID")
expect(source).toContain(
'export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }',
)
expect(source).not.toContain("Brand.Brand")
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
+2 -1
View File
@@ -1,6 +1,7 @@
export * as ConfigCommandPlugin from "./command.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import path from "path"
@@ -54,7 +55,7 @@ export const Plugin = define({
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.agent !== undefined) item.agent = Agent.ID.make(command.agent)
if (command.model !== undefined)
item.model = {
id: command.model.model,
+36 -23
View File
@@ -9,6 +9,12 @@ import type {
SessionMessageInfo,
SessionMessageUser,
} from "@opencode-ai/client/promise"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { Share } from "./share"
@@ -64,20 +70,19 @@ async function mapFromLegacySession(blob: {
function currentSession(session: typeof SessionV1.SessionInfo.Type, messages: LegacyMessage[]): SessionInfo {
const latestUser = messages.findLast((message): message is typeof SessionV1.User.Type => message.role === "user")
const model =
session.model ??
(latestUser
? {
id: latestUser.model.modelID,
providerID: latestUser.model.providerID,
...(latestUser.model.variant ? { variant: latestUser.model.variant } : {}),
}
: undefined)
(session.model && currentModel(session.model)) ??
(latestUser &&
currentModel({
id: latestUser.model.modelID,
providerID: latestUser.model.providerID,
variant: latestUser.model.variant,
}))
const agent = session.agent ?? latestUser?.agent
return {
id: session.id,
projectID: session.projectID,
...(session.parentID ? { parentID: session.parentID } : {}),
...(agent ? { agent } : {}),
id: Session.ID.make(session.id),
projectID: Project.ID.make(session.projectID),
...(session.parentID ? { parentID: Session.ID.make(session.parentID) } : {}),
...(agent ? { agent: Agent.ID.make(agent) } : {}),
...(model ? { model } : {}),
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
@@ -103,7 +108,7 @@ function currentMessages(messages: LegacyMessage[], parts: LegacyPart[]): Sessio
if (compaction?.type === "compaction")
return [
{
id: message.id,
id: SessionMessage.ID.make(message.id),
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
@@ -143,18 +148,18 @@ function currentUser(message: typeof SessionV1.User.Type, parts: LegacyPart[]):
if (!text && !files.length && !agents.length) return []
return [
{
id: message.id,
id: SessionMessage.ID.make(message.id),
type: "user",
text,
...(files.length ? { files } : {}),
...(agents.length ? { agents } : {}),
metadata: {
agent: message.agent,
model: {
agent: Agent.ID.make(message.agent),
model: currentModel({
id: message.model.modelID,
providerID: message.model.providerID,
...(message.model.variant ? { variant: message.model.variant } : {}),
},
variant: message.model.variant,
}),
},
time: { created: message.time.created },
},
@@ -163,14 +168,14 @@ function currentUser(message: typeof SessionV1.User.Type, parts: LegacyPart[]):
function currentAssistant(message: typeof SessionV1.Assistant.Type, parts: LegacyPart[]): SessionMessageAssistant {
return {
id: message.id,
id: SessionMessage.ID.make(message.id),
type: "assistant",
agent: message.agent,
model: {
agent: Agent.ID.make(message.agent),
model: currentModel({
id: message.modelID,
providerID: message.providerID,
...(message.variant ? { variant: message.variant } : {}),
},
variant: message.variant,
}),
content: parts.flatMap((part): SessionMessageAssistant["content"] => {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "reasoning")
@@ -191,6 +196,14 @@ function currentAssistant(message: typeof SessionV1.Assistant.Type, parts: Legac
}
}
function currentModel(input: { id: string; providerID: string; variant?: string }) {
return {
id: Model.ID.make(input.id),
providerID: Provider.ID.make(input.providerID),
...(input.variant ? { variant: Model.VariantID.make(input.variant) } : {}),
}
}
function currentTool(part: typeof SessionV1.ToolPart.Type, fallback: number): SessionMessageAssistantTool {
const base = {
type: "tool" as const,
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import { readShareDocument } from "../../src/core/share-document"
import { Share } from "../../src/core/share"
import { wire } from "../wire"
describe("share document", () => {
test("rejects malformed current message batches", () => {
@@ -32,8 +34,10 @@ describe("share document", () => {
const result = await readShareDocument(data)
expect(result.session.id).toBe("ses_current")
expect(result.messages).toEqual([{ id: "msg_current", type: "user", text: "Current prompt", time: { created: 1 } }])
expect(result.session.id).toBe(wire<SessionInfo["id"]>("ses_current"))
expect(result.messages).toEqual(
wire<SessionMessageInfo[]>([{ id: "msg_current", type: "user", text: "Current prompt", time: { created: 1 } }]),
)
})
test("maps a legacy Session without changing its blob", async () => {
@@ -149,31 +153,33 @@ describe("share document", () => {
expect(data).toEqual(snapshot)
expect(result.session).toMatchObject({ id: sessionID, location: { directory: "/workspace" } })
expect(result.session).toMatchObject({ model: { id: "model", providerID: "provider" }, cost: 0 })
expect(result.messages).toEqual([
{
id: messageID,
type: "user",
text: "Stored prompt\n\nVisible ignored text",
metadata: { agent: "build", model: { id: "model", providerID: "provider" } },
time: { created: 1 },
},
{
id: assistantID,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "Stored response" },
{
type: "tool",
id: "call_read",
name: "read",
state: { status: "completed", input: { path: "README.md" }, content: [{ type: "text", text: "hello" }] },
time: { created: 2 },
},
],
time: { created: 2, completed: 4 },
},
])
expect(result.messages).toEqual(
wire<SessionMessageInfo[]>([
{
id: messageID,
type: "user",
text: "Stored prompt\n\nVisible ignored text",
metadata: { agent: "build", model: { id: "model", providerID: "provider" } },
time: { created: 1 },
},
{
id: assistantID,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "Stored response" },
{
type: "tool",
id: "call_read",
name: "read",
state: { status: "completed", input: { path: "README.md" }, content: [{ type: "text", text: "hello" }] },
time: { created: 2 },
},
],
time: { created: 2, completed: 4 },
},
]),
)
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { Brand } from "effect"
export type Wire<T> =
T extends Brand.Brand<string>
? Brand.Brand.Unbranded<T>
: T extends ReadonlyArray<infer Item>
? Wire<Item>[]
: T extends object
? { [Key in keyof T]: Wire<T[Key]> }
: T
export function wire<T>(value: Wire<T>): T {
return value as T
}
+93 -25
View File
@@ -35,7 +35,7 @@ export type Contract = {
readonly groups: ReadonlyArray<Group>
}
export type EffectTypeReference = {
export type TypeReference = {
readonly schema: Schema.Top
readonly name: string
readonly import: string
@@ -46,7 +46,7 @@ export type EffectOutputType = {
readonly import: string
}
type ResolvedEffectTypeReference = Omit<EffectTypeReference, "schema"> & { readonly ast: SchemaAST.AST }
type ResolvedTypeReference = Omit<TypeReference, "schema"> & { readonly ast: SchemaAST.AST }
export class GenerationError extends Schema.TaggedError<GenerationError>()("GenerationError", {
reason: Schema.String,
@@ -309,7 +309,7 @@ export function emitEffectImported(
export function emitEffectShape(
contract: Contract,
options?: {
readonly typeReferences?: ReadonlyArray<EffectTypeReference>
readonly typeReferences?: ReadonlyArray<TypeReference>
readonly outputTypes?: Readonly<Record<string, EffectOutputType>>
},
): Output {
@@ -327,6 +327,7 @@ export function emitEffectShape(
export function emitPromise(
contract: Contract,
options?: {
readonly typeReferences?: ReadonlyArray<TypeReference>
readonly outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>
readonly mutableOutputs?: boolean
},
@@ -338,7 +339,15 @@ export function emitPromise(
return {
operations: promiseOperations(groups),
files: [
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes, options?.mutableOutputs ?? false) },
{
path: "types.ts",
content: renderPromiseTypes(
groups,
options?.typeReferences ?? [],
options?.outputTypes,
options?.mutableOutputs ?? false,
),
},
{
path: "client-error.ts",
content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse" | "SseEventTooLarge"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`,
@@ -358,10 +367,10 @@ export function emitPromise(
function renderEffectShape(
groups: ReadonlyArray<Group>,
typeReferences: ReadonlyArray<EffectTypeReference>,
typeReferences: ReadonlyArray<TypeReference>,
outputTypes?: Readonly<Record<string, EffectOutputType>>,
) {
const references = effectTypeReferences(typeReferences)
const references = resolveTypeReferences(typeReferences)
const imports = new Set<string>()
const endpointTypes = groups.map((group, groupIndex) => {
const endpoints = group.endpoints.map((endpoint, endpointIndex) => {
@@ -422,10 +431,10 @@ ${clientFields.join("\n")}
`
}
function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
const names = new Map<string, ResolvedEffectTypeReference>()
const asts = new Map<SchemaAST.AST, ResolvedEffectTypeReference>()
const brands = new Map<string, ResolvedEffectTypeReference>()
function resolveTypeReferences(input: ReadonlyArray<TypeReference>) {
const names = new Map<string, ResolvedTypeReference>()
const asts = new Map<SchemaAST.AST, ResolvedTypeReference>()
const brands = new Map<string, ResolvedTypeReference>()
for (const reference of input) {
const value = { name: reference.name, import: reference.import, ast: reference.schema.ast }
const document = SchemaRepresentation.toCodeDocument(
@@ -436,7 +445,9 @@ function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
name === undefined
? undefined
: (document.references.nonRecursives.find((item) => item.$ref === name)?.code.Type ?? name)
if (type?.includes("Brand.Brand<") && !brands.has(type)) brands.set(type, value)
if (type?.includes("Brand.Brand<") && hasRootBrand(reference.schema.ast) && !brands.has(type)) {
brands.set(type, value)
}
if (SchemaAST.resolveIdentifier(reference.schema.ast) !== undefined || type?.includes("Brand.Brand<")) {
asts.set(reference.schema.ast, value)
asts.set(Schema.toType(reference.schema).ast, value)
@@ -445,16 +456,36 @@ function effectTypeReferences(input: ReadonlyArray<EffectTypeReference>) {
const previous = names.get(name)
if (previous !== undefined) {
if (previous.ast !== reference.schema.ast) {
throw new GenerationError({ reason: `Conflicting Effect type reference: ${name}` })
throw new GenerationError({ reason: `Conflicting type reference: ${name}` })
}
continue
}
names.set(name, value)
}
return { names, asts, brands }
return {
names,
asts,
brands: new Map([...brands].sort(([left], [right]) => right.length - left.length)),
}
}
function effectType(schema: Schema.Top, references: ReturnType<typeof effectTypeReferences>, imports: Set<string>) {
function hasRootBrand(ast: SchemaAST.AST) {
const brands = ast.annotations?.brands
if (Array.isArray(brands) && brands.length > 0) return true
return checksHaveBrand(ast.checks)
}
function checksHaveBrand(checks: SchemaAST.Checks | undefined): boolean {
return (
checks?.some((check) => {
const brands = check.annotations?.brands
if (Array.isArray(brands) && brands.length > 0) return true
return check._tag === "FilterGroup" && checksHaveBrand(check.checks)
}) ?? false
)
}
function effectType(schema: Schema.Top, references: ReturnType<typeof resolveTypeReferences>, imports: Set<string>) {
const projected = Schema.toType(schema)
const direct = references.asts.get(schema.ast) ?? references.asts.get(projected.ast)
if (direct !== undefined) {
@@ -484,14 +515,23 @@ function effectType(schema: Schema.Top, references: ReturnType<typeof effectType
return type
}
let type = expand(document.codes[0].Type)
for (const [brand, reference] of references.brands) {
type = replaceBrandReferences(type, references.brands, imports)
if (type.includes("Brand.Brand<")) imports.add('import type { Brand } from "effect"')
if (type.includes("DateTime.")) imports.add('import type { DateTime } from "effect"')
if (type.includes("Schema.")) imports.add('import type { Schema } from "effect"')
return type
}
function replaceBrandReferences(
type: string,
references: ReadonlyMap<string, ResolvedTypeReference>,
imports: Set<string>,
) {
for (const [brand, reference] of references) {
if (!type.includes(brand)) continue
imports.add(reference.import)
type = type.replaceAll(brand, reference.name)
}
if (type.includes("Brand.Brand<")) imports.add('import type { Brand } from "effect"')
if (type.includes("DateTime.")) imports.add('import type { DateTime } from "effect"')
if (type.includes("Schema.")) imports.add('import type { Schema } from "effect"')
return type
}
@@ -747,9 +787,12 @@ function renderImportedProjection(groups: ReadonlyArray<Group>, endpoints: Reado
function renderPromiseTypes(
groups: ReadonlyArray<Group>,
references: ReadonlyArray<TypeReference>,
outputTypes?: Readonly<Record<string, { readonly name: string; readonly import: string }>>,
mutableOutputs = false,
) {
const brands = resolveTypeReferences(references).brands
const imports = new Set(Object.values(outputTypes ?? {}).map((override) => override.import))
const types = new Map<SchemaAST.AST, string>()
const typeOf = (schema: Schema.Top, decoded = false) => {
const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema)
@@ -759,6 +802,15 @@ function renderPromiseTypes(
types.set(projected.ast, type)
return type
}
const outputFieldTypes = new Map<SchemaAST.AST, string>()
const outputFieldTypeOf = (schema: Schema.Top) => {
const projected = Schema.toEncoded(schema)
const cached = outputFieldTypes.get(projected.ast)
if (cached !== undefined) return cached
const type = structuralType(projected, brands, imports)
outputFieldTypes.set(projected.ast, type)
return type
}
const outputMarkers = new Map<SchemaAST.AST, string>()
const outputSchemas: Array<Schema.Top> = []
const outputTypeOf = (schema: Schema.Top) => {
@@ -782,7 +834,10 @@ function renderPromiseTypes(
)
const errorTypes = Array.from(errors.values()).map((error) => {
const fields = error.fields
.map(([name, schema, optional]) => `readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${typeOf(schema)}`)
.map(
([name, schema, optional]) =>
`readonly ${JSON.stringify(name)}${optional ? "?" : ""}: ${outputFieldTypeOf(schema)}`,
)
.join("; ")
return `export type ${error.identifier} = { readonly ${JSON.stringify(error.key)}: ${JSON.stringify(error.tag)}; ${fields} }\nexport const is${error.identifier} = (value: unknown): value is ${error.identifier} => typeof value === "object" && value !== null && ${JSON.stringify(error.key)} in value && value[${JSON.stringify(error.key)}] === ${JSON.stringify(error.tag)}`
})
@@ -834,7 +889,7 @@ function renderPromiseTypes(
),
...Object.values(outputTypes ?? {}).map((output) => output.name),
])
const rendered = structuralTypes(outputSchemas, mutableOutputs, reservedNames)
const rendered = structuralTypes(outputSchemas, mutableOutputs, reservedNames, brands, imports)
const resolve = (source: string) =>
rendered.types.reduce((result, type, index) => result.replaceAll(`__PROMISE_TYPE_${index}__`, type), source)
const resolvedErrors = errorTypes.map(resolve)
@@ -844,7 +899,6 @@ function renderPromiseTypes(
)
? `export type JsonValue = null | boolean | number | string | ${mutableOutputs ? "Array<JsonValue> | { [key: string]: JsonValue }" : "ReadonlyArray<JsonValue> | { readonly [key: string]: JsonValue }"}`
: ""
const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))]
return [...imports, json, ...rendered.definitions, ...resolvedErrors, resolvedOperations].filter(Boolean).join("\n\n")
}
@@ -982,7 +1036,13 @@ function identifierPart(value: string) {
.join("")
}
function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, reservedNames: ReadonlySet<string>) {
function structuralTypes(
schemas: ReadonlyArray<Schema.Top>,
mutable: boolean,
reservedNames: ReadonlySet<string>,
brandReferences: ReadonlyMap<string, ResolvedTypeReference>,
imports: Set<string>,
) {
if (schemas.length === 0) return { types: [], definitions: [] }
const representations = SchemaRepresentation.toRepresentations(
promiseTypeAsts(schemas) as [SchemaAST.AST, ...Array<SchemaAST.AST>],
@@ -1036,7 +1096,7 @@ function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, r
const pattern = `(?<![A-Za-z0-9_$.'"])${reference.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![A-Za-z0-9_$.'"])`
type = type.replace(new RegExp(pattern, "g"), name)
}
const output = type
const output = replaceBrandReferences(type, brandReferences, imports)
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
.replaceAll("Schema.Json", "JsonValue")
.replaceAll(/(?<!["'])\bunknown\b(?!["'])/g, "any")
@@ -1074,7 +1134,11 @@ function uniqueTypeName(seed: string, used: ReadonlySet<string>, suffix = 1): st
return used.has(name) ? uniqueTypeName(seed, used, suffix + 1) : name
}
function structuralType(schema: Schema.Top) {
function structuralType(
schema: Schema.Top,
brandReferences?: ReadonlyMap<string, ResolvedTypeReference>,
imports?: Set<string>,
) {
const document = SchemaRepresentation.toCodeDocument(SchemaRepresentation.toRepresentations([promiseTypeAst(schema)]))
if (
document.artifacts.some(
@@ -1099,8 +1163,12 @@ function structuralType(schema: Schema.Top) {
}
return type
}
const type = expand(document.codes[0].Type)
return preserveStringSuggestions(
expand(document.codes[0].Type)
(brandReferences === undefined || imports === undefined
? type
: replaceBrandReferences(type, brandReferences, imports)
)
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
.replaceAll("Schema.Json", "JsonValue"),
)
@@ -562,6 +562,74 @@ describe("HttpApiCodegen.generate", () => {
expect(types).not.toContain("Brand")
})
test("imports authoritative brands into Promise output types", () => {
const SessionID = Schema.String.pipe(Schema.brand("SessionID"))
const InternalID = Schema.String.pipe(Schema.brand("InternalID"))
const Info = Schema.Struct({ id: SessionID, internalID: InternalID }).annotate({ identifier: "Session.Info" })
class Missing extends Schema.TaggedError<Missing>()("Missing", { sessionID: SessionID }, { httpApiStatus: 404 }) {}
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("get", "/session/:sessionID", {
params: { sessionID: SessionID },
success: Schema.Struct({ data: Info }),
error: Missing,
}),
),
),
{
typeReferences: [
{
schema: Info,
name: "Session.Info",
import: 'import type { Session } from "@example/schema/session"',
},
{
schema: SessionID,
name: "Session.ID",
import: 'import type { Session } from "@example/schema/session"',
},
],
},
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('import type { Session } from "@example/schema/session"')
expect(types).toContain('readonly "sessionID": string')
expect(types).toContain('export type SessionInfo = { readonly "id": Session.ID, readonly "internalID": string }')
expect(types).toContain('readonly "sessionID": Session.ID')
expect(types).not.toContain("Session.Info")
expect(types).not.toContain("Brand")
})
test("prefers the most specific registered Promise output brand", () => {
const EntityID = Schema.String.pipe(Schema.brand("EntityID"))
const SessionID = EntityID.pipe(Schema.brand("SessionID"))
const output = emitPromise(
compileContract(api(HttpApiEndpoint.get("get", "/session", { success: Schema.Struct({ id: SessionID }) }))),
{
typeReferences: [
{
schema: EntityID,
name: "Entity.ID",
import: 'import type { Entity } from "@example/schema/entity"',
},
{
schema: SessionID,
name: "Session.ID",
import: 'import type { Session } from "@example/schema/session"',
},
],
},
)
const types = output.files.find((file) => file.path === "types.ts")?.content
expect(types).toContain('import type { Session } from "@example/schema/session"')
expect(types).toContain('export type SessionGetOutput = { readonly "id": Session.ID }')
expect(types).not.toContain('import type { Entity } from "@example/schema/entity"')
expect(types).not.toContain("Brand")
})
test("preserves suggestions for open string unions in Promise wire types", () => {
const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
identifier: "Field",
+1
View File
@@ -60,6 +60,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
@@ -10,21 +10,30 @@ import type {
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import { Agent } from "@opencode-ai/schema/agent"
import { Form } from "@opencode-ai/schema/form"
import { Model } from "@opencode-ai/schema/model"
import { Permission } from "@opencode-ai/schema/permission"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import type { SessionDocument } from "../document"
import type { SessionUserPresentation } from "../timeline/session-timeline"
export const CURRENT_SESSION_ID = "session_current_story"
export const CURRENT_SESSION_ID = Session.ID.make("session_current_story")
export const STORY_TIME = 1_735_689_600_000
export const STORY_MODEL = {
id: "claude-sonnet-4",
providerID: "anthropic",
variant: "balanced",
id: Model.ID.make("claude-sonnet-4"),
providerID: Provider.ID.make("anthropic"),
variant: Model.VariantID.make("balanced"),
} satisfies ModelRef
function user(id: string, text: string, offset: number): SessionMessageUser {
return {
id,
id: SessionMessage.ID.make(id),
type: "user",
text,
time: { created: STORY_TIME + offset },
@@ -42,9 +51,9 @@ function assistant(input: {
agent?: string
}): SessionMessageAssistant {
return {
id: input.id,
id: SessionMessage.ID.make(input.id),
type: "assistant",
agent: input.agent ?? "build",
agent: Agent.ID.make(input.agent ?? "build"),
model: STORY_MODEL,
content: input.content,
error: input.error,
@@ -316,9 +325,9 @@ export const editThenTestDocument = {
export const standaloneShellRunningDocument = document(
[
{
id: "msg_shell_running",
id: SessionMessage.ID.make("msg_shell_running"),
type: "shell",
shellID: "shell_running",
shellID: Shell.ID.make("sh_running"),
command: "bun run storybook --ci",
status: "running",
output: {
@@ -335,9 +344,9 @@ export const standaloneShellRunningDocument = document(
export const standaloneShellCompletedDocument = document([
{
id: "msg_shell_completed",
id: SessionMessage.ID.make("msg_shell_completed"),
type: "shell",
shellID: "shell_completed",
shellID: Shell.ID.make("sh_completed"),
command: "git status --short",
status: "exited",
exit: 0,
@@ -730,16 +739,16 @@ export const webResearchDocument = document([
export const skillWorkflowDocument = document([
{
id: "msg_agent_switched_review",
id: SessionMessage.ID.make("msg_agent_switched_review"),
type: "agent-switched",
agent: "review",
previous: "build",
agent: Agent.ID.make("review"),
previous: Agent.ID.make("build"),
time: { created: STORY_TIME + 78_000 },
},
{
id: "msg_skill_loaded_rtl",
id: SessionMessage.ID.make("msg_skill_loaded_rtl"),
type: "skill",
skill: "rtl-aware-development",
skill: Skill.ID.make("rtl-aware-development"),
name: "RTL-aware development",
text: "Verify direction independently from language.",
time: { created: STORY_TIME + 78_500 },
@@ -817,7 +826,7 @@ export const questionPendingDocument = document(
)
export const activeQuestionRequest = {
id: "form_session_layout",
id: Form.ID.make("frm_session_layout"),
sessionID: CURRENT_SESSION_ID,
title: "Session layout",
metadata: { kind: "question" },
@@ -874,7 +883,7 @@ export const compactionDocument = document([
error: { type: "ExecutionInterrupted", message: "Context compaction started" },
}),
{
id: "msg_compaction_complete",
id: SessionMessage.ID.make("msg_compaction_complete"),
type: "compaction",
status: "completed",
reason: "auto",
@@ -1039,7 +1048,7 @@ export const largeCompletedDocument = {
} satisfies SessionDocument
export const activePermissionRequest = {
id: "permission_publish_canary",
id: Permission.ID.make("permission_publish_canary"),
sessionID: CURRENT_SESSION_ID,
action: "shell",
resources: ["npm publish --tag canary"],
@@ -8,6 +8,7 @@ import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { Button } from "@opencode-ai/ui/button"
import { Show, createSignal, type JSX } from "solid-js"
import { CURRENT_SESSION_ID, STORY_TIME } from "./current-session-fixtures"
import { Session } from "@opencode-ai/schema/session"
export function CurrentSessionProviders(props: { document: SessionDocument; children: JSX.Element }) {
return (
@@ -32,13 +33,13 @@ export function CurrentSessionProviders(props: { document: SessionDocument; chil
time: { created: STORY_TIME, updated: STORY_TIME + 300_000 },
},
{
id: "session_child_review",
id: Session.ID.make("session_child_review"),
parentID: CURRENT_SESSION_ID,
title: "Review current Session fixtures",
time: { created: STORY_TIME + 71_000, updated: STORY_TIME + 72_000 },
},
{
id: "session_child_tests",
id: Session.ID.make("session_child_tests"),
parentID: CURRENT_SESSION_ID,
title: "Check the Storybook scenarios",
time: { created: STORY_TIME + 73_000, updated: STORY_TIME + 74_000 },
+14
View File
@@ -0,0 +1,14 @@
import type { Brand } from "effect"
export type Wire<T> =
T extends Brand.Brand<string>
? Brand.Brand.Unbranded<T>
: T extends ReadonlyArray<infer Item>
? Wire<Item>[]
: T extends object
? { [Key in keyof T]: Wire<T[Key]> }
: T
export function wire<T>(value: Wire<T>): T {
return value as T
}
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { ModelRef, SessionMessageInfo } from "@opencode-ai/client/promise"
import { createTimelineProjection, reuseTimelineRows, TimelineRow, type PartGroup } from "./projection"
import { wire } from "../test-fixture"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
new TimelineRow.AssistantPart({
@@ -96,9 +97,9 @@ describe("reuseTimelineRows", () => {
describe("createTimelineProjection", () => {
test("builds current message, parent, context, and row indexes", () => {
const selectedModel = { id: "selected", providerID: "provider" } satisfies ModelRef
const assistantModel = { id: "assistant", providerID: "provider", variant: "fast" } satisfies ModelRef
const messages = [
const selectedModel = wire<ModelRef>({ id: "selected", providerID: "provider" })
const assistantModel = wire<ModelRef>({ id: "assistant", providerID: "provider", variant: "fast" })
const messages = wire<SessionMessageInfo[]>([
{ id: "agent", type: "agent-switched", agent: "explore", time: { created: 1 } },
{ id: "model", type: "model-switched", model: selectedModel, time: { created: 2 } },
{ id: "user-1", type: "user", text: "first", time: { created: 3 } },
@@ -120,7 +121,7 @@ describe("createTimelineProjection", () => {
},
time: { created: 6 },
},
] satisfies SessionMessageInfo[]
])
const result = createTimelineProjection({
sessionMessages: messages,
@@ -130,14 +131,16 @@ describe("createTimelineProjection", () => {
expect(result.activeMessageID).toBe("user-2")
expect(result.messageByID).toBe(result.sessionMessageByID)
expect(result.sessionMessageByID.get("assistant-1")).toBe(messages[3])
expect(result.assistantMessagesByParent.get("user-1")?.map((message) => message.id)).toEqual(["assistant-1"])
expect(result.sessionMessageByID.get(messages[3].id)).toBe(messages[3])
expect(result.assistantMessagesByParent.get(messages[2].id)?.map((message) => message.id)).toEqual([messages[3].id])
expect(result.assistantMessagesByParent.has("user-2")).toBe(false)
expect(result.userContextByID.get("user-1")).toEqual({ agent: "build", model: assistantModel })
expect(result.userContextByID.get("user-2")).toEqual({
agent: "review",
model: { id: "override", providerID: "custom", variant: "precise" },
})
expect(result.userContextByID.get("user-2")).toEqual(
wire<{ agent: string; model: ModelRef }>({
agent: "review",
model: { id: "override", providerID: "custom", variant: "precise" },
}),
)
expect(result.messageRowIndex.get("user-1")).toBe(0)
expect(result.messageLastRowIndex.get("user-1")).toBe(3)
expect(result.lastAssistantGroupKey.get("user-1")).toBe("part:assistant-1:assistant-1:text:0")
@@ -145,7 +148,7 @@ describe("createTimelineProjection", () => {
})
test("reuses a stable projected row array", () => {
const messages = [
const messages = wire<SessionMessageInfo[]>([
{ id: "user-1", type: "user", text: "first", time: { created: 1 } },
{
id: "assistant-1",
@@ -155,7 +158,7 @@ describe("createTimelineProjection", () => {
content: [{ type: "text", text: "answer" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
])
const first = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
+11 -6
View File
@@ -6,6 +6,8 @@ import type {
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Option, Schema } from "effect"
import { createMemo, type Accessor } from "solid-js"
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
@@ -20,6 +22,7 @@ type PriorContext = { index: number; row: ContextRow }
const contextTools = new Set(["read", "glob", "grep", "list"])
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const emptyModel: ModelRef = { id: Model.ID.make(""), providerID: Provider.ID.make("") }
export type TimelineProjectionInput = {
sessionMessages: SessionMessageInfo[]
@@ -29,7 +32,9 @@ export type TimelineProjectionInput = {
}
export function createTimelineProjection(input: TimelineProjectionInput) {
const sessionMessageByID = new Map(input.sessionMessages.map((message) => [message.id, message] as const))
const sessionMessageByID = new Map<string, SessionMessageInfo>(
input.sessionMessages.map((message) => [message.id, message]),
)
const projection = Timeline.constructSessionMessageRows(
input.sessionMessages,
input.showReasoningSummaries,
@@ -67,7 +72,7 @@ export function createReactiveTimelineProjection(input: {
showReasoningSummaries: Accessor<boolean>
}) {
const sessionMessageByID = createMemo(
() => new Map(input.sessionMessages().map((message) => [message.id, message] as const)),
() => new Map<string, SessionMessageInfo>(input.sessionMessages().map((message) => [message.id, message])),
)
const userContextByID = createMemo(() => indexUserContext(input.sessionMessages()))
const assistantMessagesByParent = createMemo(() => indexAssistantMessages(input.sessionMessages()))
@@ -334,7 +339,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine
function indexUserContext(messages: SessionMessageInfo[]) {
const result = new Map<string, { agent: string; model: ModelRef }>()
let agent = ""
let model: ModelRef = { id: "", providerID: "" }
let model: ModelRef = emptyModel
let userID: string | undefined
messages.forEach((message) => {
@@ -362,9 +367,9 @@ function indexUserContext(messages: SessionMessageInfo[]) {
localModelID &&
typeof localModel.providerID === "string"
? {
id: localModelID,
providerID: localModel.providerID,
variant: typeof localModel.variant === "string" ? localModel.variant : undefined,
id: Model.ID.make(localModelID),
providerID: Provider.ID.make(localModel.providerID),
variant: typeof localModel.variant === "string" ? Model.VariantID.make(localModel.variant) : undefined,
}
: model,
})
@@ -1,10 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
import { wire } from "../test-fixture"
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{ id: "msg_1", type: "user", text: "first", time: { created: 1 } },
{
id: "msg_2",
@@ -23,7 +24,7 @@ describe("current session timeline rows", () => {
content: [{ type: "reasoning", text: "working" }],
time: { created: 5 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
expect(result.activeMessageID).toBe("msg_3")
@@ -37,7 +38,7 @@ describe("current session timeline rows", () => {
})
test("renders a current shell message as a standalone turn", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{
id: "msg_shell",
type: "shell",
@@ -48,7 +49,7 @@ describe("current session timeline rows", () => {
output: { output: "/repo", cursor: 5, size: 5, truncated: false },
time: { created: 1, completed: 2 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
expect(result.activeMessageID).toBe("msg_shell")
@@ -56,7 +57,7 @@ describe("current session timeline rows", () => {
})
test("keeps assistant content when no user root is available", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{
id: "msg_notice",
type: "synthetic",
@@ -72,7 +73,7 @@ describe("current session timeline rows", () => {
content: [{ type: "text", text: "result" }],
time: { created: 2, completed: 3 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
@@ -84,7 +85,7 @@ describe("current session timeline rows", () => {
})
test("keeps CLI notice messages between the assistant steps they surround", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{ id: "msg_user", type: "user", text: "run", time: { created: 1 } },
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
{
@@ -134,7 +135,7 @@ describe("current session timeline rows", () => {
recent: "recent",
time: { created: 11 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "idle" })
expect(result.rows.map(TimelineRow.key)).toEqual([
@@ -151,10 +152,10 @@ describe("current session timeline rows", () => {
})
test("renders an optimistic user turn and thinking before the protocol message arrives", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{ id: "msg_z", type: "user", text: "existing", time: { created: 1 } },
{ id: "msg_a", type: "user", text: "pending", time: { created: 2 } },
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
expect(result.activeMessageID).toBe("msg_a")
@@ -167,7 +168,7 @@ describe("current session timeline rows", () => {
})
test("renders retry state from the current assistant message", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{ id: "msg_user", type: "user", text: "retry", time: { created: 1 } },
{
id: "msg_assistant",
@@ -178,7 +179,7 @@ describe("current session timeline rows", () => {
retry: { attempt: 2, at: 10, error: { type: "ProviderError", message: "rate limited" } },
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
@@ -186,7 +187,7 @@ describe("current session timeline rows", () => {
})
test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
{
id: "msg_failed",
@@ -205,14 +206,14 @@ describe("current session timeline rows", () => {
content: [{ type: "text", text: "streaming again" }],
time: { created: 4 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, true, { type: "busy" })
expect(result.rows.map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
})
test("keeps content IDs and groups adjacent context tools", () => {
const source = [
const source = wire<SessionMessageInfo[]>([
{ id: "msg_user", type: "user", text: "inspect", time: { created: 1 } },
{
id: "msg_assistant",
@@ -254,7 +255,7 @@ describe("current session timeline rows", () => {
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
])
const result = Timeline.constructSessionMessageRows(source, false, { type: "idle" })
const groups = result.rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group] : []))
@@ -277,7 +278,7 @@ describe("current session timeline rows", () => {
})
test("places a divider after interrupted output unless the turn compacts", () => {
const messages = [
const messages = wire<SessionMessageInfo[]>([
{ id: "msg_user", type: "user", text: "continue", time: { created: 1 } },
{
id: "msg_interrupted",
@@ -296,7 +297,7 @@ describe("current session timeline rows", () => {
content: [{ type: "text", text: "after" }],
time: { created: 4, completed: 5 },
},
] satisfies SessionMessageInfo[]
])
expect(Timeline.constructSessionMessageRows(messages, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual([
"UserMessage",
@@ -305,7 +306,7 @@ describe("current session timeline rows", () => {
"AssistantPart",
])
const compacted = [
const compacted = wire<SessionMessageInfo[]>([
messages[0],
messages[1],
{
@@ -318,7 +319,7 @@ describe("current session timeline rows", () => {
time: { created: 4 },
},
messages[2],
] satisfies SessionMessageInfo[]
])
expect(Timeline.constructSessionMessageRows(compacted, true, { type: "idle" }).rows.map((row) => row._tag)).toEqual(
["UserMessage", "AssistantPart", "Notice", "AssistantPart"],
@@ -4,6 +4,8 @@ import type {
SessionMessageUser,
SessionStatus,
} from "@opencode-ai/client/promise"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Card } from "@opencode-ai/ui/card"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
@@ -22,6 +24,7 @@ import { SessionRetry } from "../components/session-retry"
import { createReactiveTimelineProjection, Timeline, TimelineRow } from "./projection"
const emptyAssistantMessages: SessionMessageAssistant[] = []
const emptyModel = { id: Model.ID.make(""), providerID: Provider.ID.make("") }
type Projection = ReturnType<typeof createReactiveTimelineProjection>
type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, TimelineRow.TurnGap>
@@ -220,7 +223,7 @@ export function createSessionTimelineRowRenderer(input: {
displayText={presentation()?.displayText}
comments={presentation()?.comments}
historicalAgent={context()?.agent ?? ""}
historicalModel={context()?.model ?? { id: "", providerID: "" }}
historicalModel={context()?.model ?? emptyModel}
actions={input.actions}
/>
</div>
@@ -10,6 +10,7 @@ import type {
FormFields,
FormValue,
} from "@opencode-ai/client"
import type { Provider } from "@opencode-ai/schema/provider"
import open from "open"
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClipboard } from "../context/clipboard"
@@ -36,7 +37,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = {
type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }>
type IntegrationAttempt = IntegrationOauthConnectOutput["data"]
type CommandAttempt = IntegrationCommandConnectOutput["data"]
type OnIntegrationConnected = (providerID?: string) => void
type OnIntegrationConnected = (providerID?: Provider.ID) => void
const CANCELLED = Symbol("cancelled")
const CUSTOM = Symbol("custom")
const OPEN = Symbol("open")
@@ -95,7 +96,7 @@ export function DialogIntegration(
const options = createMemo(() => {
const providers = data.location.websearch.list() ?? []
const providersByID = new Map(providers.map((provider) => [provider.id, provider]))
const providersByID = new Map(providers.map((provider) => [String(provider.id), provider]))
return integrations().map((integration) => {
const methods = connectMethods(integration)
const provider = providersByID.get(integration.id)
@@ -914,7 +915,7 @@ async function connected(
function providerID(data: ReturnType<typeof useData>, integrationID: string) {
const models = data.location.model.list() ?? []
const matches = (data.location.provider.list() ?? []).filter(
(provider) => provider.integrationID === integrationID || provider.id === integrationID,
(provider) => provider.integrationID === integrationID || String(provider.id) === integrationID,
)
return (
matches.find((provider) =>
+15 -7
View File
@@ -9,8 +9,10 @@ import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { modelPreferenceKey } from "../model-preference"
import { useLocation } from "../context/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
export function DialogModel(props: { providerID?: string }) {
export function DialogModel(props: { providerID?: Provider.ID }) {
const local = useLocal()
const data = useData()
const dialog = useDialog()
@@ -19,9 +21,10 @@ export function DialogModel(props: { providerID?: string }) {
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
const connected = useConnected()
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const providers = createMemo(() => {
const values = data.location.provider.list(location.ref) ?? []
return new Map(values.map((item) => [item.id, item]))
})
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
@@ -118,12 +121,17 @@ export function DialogModel(props: { providerID?: string }) {
if (!value) return "Select model"
return value.name
})
const current = createMemo(() => {
const value = local.model.current()
if (!value) return
return { providerID: Provider.ID.make(value.providerID), modelID: Model.ID.make(value.modelID) }
})
function onSelect(providerID: string, modelID: string) {
function onSelect(providerID: Provider.ID, modelID: Model.ID) {
local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list()
const cur = local.model.variant.current()
if (cur && list.includes(cur)) {
if (cur && list.some((variant) => variant === cur)) {
dialog.clear()
return
}
@@ -163,7 +171,7 @@ export function DialogModel(props: { providerID?: string }) {
flat={true}
skipFilter={true}
title={title()}
current={local.model.current()}
current={current()}
focusCurrent={false}
/>
)
@@ -143,14 +143,14 @@ export function DialogSessionList() {
const options = createMemo(() => {
const today = new Date().toDateString()
const sessionMap = new Map(
const sessionMap = new Map<string, SessionInfo>(
sessions()
.filter((session) => !session.parentID)
.map((session) => [session.id, session]),
)
const pinned = sessionTabs.enabled() ? [] : local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
const pinnedSet = new Set(pinned)
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
const pinnedSet = new Set<string>(pinned)
const slotByID = new Map<string, number>(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
+5 -3
View File
@@ -33,6 +33,8 @@ import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
@@ -1219,9 +1221,9 @@ export function Prompt(props: PromptProps) {
location: directory ? { directory } : location,
agent: agent.id,
model: {
providerID: selection.providerID,
id: selection.modelID,
variant,
providerID: Provider.ID.make(selection.providerID),
id: Model.ID.make(selection.modelID),
variant: variant === undefined ? undefined : Model.VariantID.make(variant),
},
})
sessionID = created.id
+2 -2
View File
@@ -423,7 +423,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
current() {
const v = this.selected()
if (v && this.list().includes(v)) return v
if (v && this.list().some((variant) => variant === v)) return v
return undefined
},
list() {
@@ -494,7 +494,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const slots = createMemo(() => {
const existing = new Set(
const existing = new Set<string>(
data.session
.list()
.filter((x) => x.parentID === undefined)
+8 -5
View File
@@ -16,6 +16,9 @@
// the synthetic tool parts through the same callbacks used by the live footer.
import path from "path"
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { Form } from "@opencode-ai/schema/form"
import { Permission } from "@opencode-ai/schema/permission"
import { Session } from "@opencode-ai/schema/session"
import { parseSlashHead } from "../prompt/parse"
import { writeSessionOutput } from "./stream"
import { toolCommit, toolFinalPhase } from "./stream-v2.subagent"
@@ -251,7 +254,7 @@ function split(text: string): string[] {
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
}
function take(state: State, key: "msg" | "part" | "call" | "perm", prefix: string): string {
function take(state: State, key: "msg" | "part" | "call", prefix: string): string {
state[key] += 1
return `demo_${prefix}_${state[key]}`
}
@@ -358,7 +361,7 @@ function startTool(state: State, ref: Ref, metadata: Record<string, JsonValue> =
function askPermission(state: State, item: Permit): void {
const tool = startTool(state, item.ref)
const id = take(state, "perm", "perm")
const id = `per_demo_${++state.perm}`
state.perms.set(id, {
ref: item.ref,
done: item.done,
@@ -367,8 +370,8 @@ function askPermission(state: State, item: Permit): void {
present(state, [], {
type: "permission",
request: {
id,
sessionID: state.id,
id: Permission.ID.make(id),
sessionID: Session.ID.make(state.id),
action: item.permission,
resources: item.patterns,
metadata: item.metadata ?? {},
@@ -800,7 +803,7 @@ function emitForm(state: State, kind: FormKind = "question"): void {
startTool(state, ref)
state.form++
const request: MiniFormRequest = {
id: `frm_demo_${state.form}`,
id: Form.ID.make(`frm_demo_${state.form}`),
sessionID: state.id,
title: form.title,
metadata:
+5 -3
View File
@@ -210,7 +210,9 @@ export function Session(props: { verticalTabsWidth: number }) {
const pendingUsers = createMemo(() =>
data.session.pending.list(route.sessionID).flatMap((item) => (item.type === "user" ? [item] : [])),
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const pendingDeliveries = createMemo(
() => new Map<string, SessionInbox.Delivery>(pendingUsers().map((item) => [item.id, item.delivery])),
)
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
)
@@ -273,8 +275,8 @@ export function Session(props: { verticalTabsWidth: number }) {
if (id === sessionID) setRowsSynced(true)
},
)
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
const boundaries = createMemo<string[]>(() => messageBoundaryIDs(rows, messages()).filter((id) => id !== undefined))
const boundaryIDs = createMemo(() => new Set<string>(boundaries()))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [synced, setSynced] = createSignal(false)
@@ -24,7 +24,7 @@ export function findMessageBoundary(input: {
currentID?: string
userOnly?: boolean
}) {
const messages = new Map(input.messages.map((message) => [message.id, message]))
const messages = new Map<string, SessionMessageInfo>(input.messages.map((message) => [message.id, message]))
const visible = input.children
.flatMap((child) => {
if (!child.id) return []
+49 -40
View File
@@ -1,9 +1,14 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import type { OpenCodeEvent } from "@opencode-ai/client"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Session } from "@opencode-ai/schema/session"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { wire } from "./fixture/wire"
const sessionID = Session.ID.make("ses_dummy")
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
@@ -26,7 +31,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
app: { name: "test", version: "test", channel: "test-sighup" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
@@ -68,7 +73,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
let promptRequests = 0
const calls = createFetch((url) => {
const session = {
id: "dummy",
id: sessionID,
title: "Demo session",
projectID: "project",
location: { directory },
@@ -81,11 +86,11 @@ test("session lifecycle updates the terminal title and prints the epilogue after
data: [session],
cursor: {},
})
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/session/dummy/prompt") {
if (url.pathname === `/api/session/${sessionID}`) return json({ data: session })
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${sessionID}/permission`) return json({ data: [] })
if (url.pathname === `/api/session/${sessionID}/prompt`) {
promptRequests++
return json({ data: {} })
}
@@ -102,30 +107,32 @@ test("session lifecycle updates the terminal title and prints the epilogue after
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
app: { name: "test", version: "test", channel: "test-session-title" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await initialTitleSet
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Renamed session" },
})
events.emit(
wire<OpenCodeEvent>({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, title: "Renamed session" },
}),
)
await renamedTitleSet
setup.renderer.destroy()
await task
expect(stdout).toContain("Renamed session")
expect(stdout).toContain("opencode2 -s dummy")
expect(stdout).toContain(`opencode2 -s ${sessionID}`)
expect(promptRequests).toBe(0)
} finally {
process.stdout.write = originalWrite
@@ -149,7 +156,7 @@ test("session title generated while an untitled session is loading remains visib
const releaseSession = Promise.withResolvers<void>()
let sessionRequests = 0
const session = {
id: "dummy",
id: sessionID,
projectID: "project",
location: { directory },
cost: 0,
@@ -159,16 +166,16 @@ test("session title generated while an untitled session is loading remains visib
const events = createEventStream()
const calls = createFetch(async (url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy") {
if (url.pathname === `/api/session/${sessionID}`) {
sessionRequests++
sessionRequested.resolve()
if (sessionRequests === 2) renameSyncRequested.resolve()
await releaseSession.promise
return json({ data: session })
}
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${sessionID}/permission`) return json({ data: [] })
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
@@ -176,24 +183,26 @@ test("session title generated while an untitled session is loading remains visib
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
app: { name: "test", version: "test", channel: "test-generated-title" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await sessionRequested.promise
events.emit({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: { sessionID: "dummy", title: "Generated title" },
})
events.emit(
wire<OpenCodeEvent>({
id: "evt_renamed",
created: 1,
type: "session.renamed",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, title: "Generated title" },
}),
)
await Promise.race([
renameSyncRequested.promise,
Bun.sleep(2_000).then(() => {
@@ -226,7 +235,7 @@ test("session startup prompt is submitted exactly once", async () => {
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
id: sessionID,
title: "Demo session",
projectID: "project",
location: { directory: cwd },
@@ -241,10 +250,10 @@ test("session startup prompt is submitted exactly once", async () => {
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === `/api/session/${sessionID}`) return json({ data: session })
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${sessionID}/permission`) return json({ data: [] })
if (url.pathname === "/api/agent")
return json({
location,
@@ -255,7 +264,7 @@ test("session startup prompt is submitted exactly once", async () => {
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
if (url.pathname === "/api/session/dummy/prompt") {
if (url.pathname === `/api/session/${sessionID}/prompt`) {
bodies.push(await request.json())
promptSubmitted.resolve()
return json({ data: {} })
@@ -267,12 +276,12 @@ test("session startup prompt is submitted exactly once", async () => {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
app: { name: "test", version: "test", channel: "test-startup-prompt" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({}), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy", prompt: "RESUME_READY" },
args: { sessionID, prompt: "RESUME_READY" },
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
@@ -307,7 +316,7 @@ test("configured app bindings execute settings and permission commands", async (
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
app: { name: "test", version: "test", channel: "test-bindings" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
@@ -6,12 +6,14 @@ import {
credentialConnections,
integrationOptions,
} from "../../../../src/component/dialog-integration"
import { wire, type Wire } from "../../../fixture/wire"
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
methods: [],
connections: [],
...value,
})
const integration = (value: Wire<Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">>): IntegrationInfo =>
wire<IntegrationInfo>({
methods: [],
connections: [],
...value,
})
describe("integrationOptions", () => {
test("keeps popular integrations first and sorts the rest alphabetically", () => {
@@ -22,7 +24,7 @@ describe("integrationOptions", () => {
integration({ id: "custom-z", name: "Zebra" }),
integration({ id: "anthropic", name: "Anthropic" }),
]).map((item) => item.id),
).toEqual(["openai", "anthropic", "mistral", "custom-z"])
).toEqual(wire<IntegrationInfo["id"][]>(["openai", "anthropic", "mistral", "custom-z"]))
})
})
@@ -57,7 +59,7 @@ describe("credentialConnections", () => {
],
}),
),
).toEqual([{ type: "credential", id: "cred_1", label: "Work" }])
).toEqual(wire<ReturnType<typeof credentialConnections>>([{ type: "credential", id: "cred_1", label: "Work" }]))
})
})
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import Notifications from "../../../../src/feature-plugins/system/notifications"
import type { OpenCodeEvent, PermissionAsked } from "@opencode-ai/client"
import type { AttentionNotifyOptions, Context } from "@opencode-ai/plugin/tui/context"
import { wire, type Wire } from "../../../fixture/wire"
type Session = { id: string; title: string; parentID?: string }
@@ -52,30 +53,31 @@ async function setup() {
return {
notifications,
emit(event: OpenCodeEvent) {
for (const handler of handlers.get(event.type) ?? []) handler(event)
emit(event: Wire<OpenCodeEvent>) {
const value = wire<OpenCodeEvent>(event)
for (const handler of handlers.get(value.type) ?? []) handler(value)
},
}
}
function form(id: string, sessionID = "session"): Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"] {
return {
return wire<Extract<OpenCodeEvent, { type: "form.created" }>["data"]["form"]>({
id,
sessionID,
title: "Input requested",
fields: [{ key: "authorization", type: "external", url: "https://example.com" }],
}
})
}
function permission(id: string, sessionID = "session"): PermissionAsked["data"] {
return {
return wire<PermissionAsked["data"]>({
id,
sessionID,
action: "edit",
resources: [],
metadata: {},
save: [],
}
})
}
function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } {
@@ -83,27 +85,27 @@ function durable(sessionID: string): { aggregateID: string; seq: number; version
}
function executionStarted(id: string, sessionID = "session"): OpenCodeEvent {
return {
return wire<OpenCodeEvent>({
id,
created: 0,
type: "session.execution.started",
durable: durable(sessionID),
data: { sessionID },
}
})
}
function executionSucceeded(id: string, sessionID = "session"): OpenCodeEvent {
return {
return wire<OpenCodeEvent>({
id,
created: 0,
type: "session.execution.succeeded",
durable: durable(sessionID),
data: { sessionID },
}
})
}
function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
return {
return wire<OpenCodeEvent>({
id,
created: 0,
type: "session.execution.failed",
@@ -112,7 +114,7 @@ function executionFailed(id: string, sessionID = "session"): OpenCodeEvent {
sessionID,
error: { type: "unknown", message: "boom" },
},
}
})
}
const formNotification: AttentionNotifyOptions = {
+158 -112
View File
@@ -1,10 +1,18 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent } from "@opencode-ai/client"
import type { OpenCodeEvent, SessionInboxInfo } from "@opencode-ai/client"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Bus } from "@opencode-ai/core/bus"
import { Agent } from "@opencode-ai/schema/agent"
import { Event } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/schema/form"
import { Permission } from "@opencode-ai/schema/permission"
import { Project } from "@opencode-ai/schema/project"
import { Session } from "@opencode-ai/schema/session"
import { Shell } from "@opencode-ai/schema/shell"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Workspace } from "@opencode-ai/schema/workspace"
import { createEffect, onMount, type ParentProps } from "solid-js"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
@@ -19,6 +27,7 @@ import { createApi, createEventStream, createFetch, directory, json, worktree }
import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { wire, type Wire } from "../../fixture/wire"
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
{
@@ -36,8 +45,8 @@ async function wait(fn: () => boolean, timeout = 2000) {
}
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
function emitEvent(events: ReturnType<typeof createEventStream>, event: Wire<OpenCodeEvent>) {
events.emit(wire<OpenCodeEvent>({ ...event, location: { directory } }))
}
const config = createTuiResolvedConfig()
@@ -194,14 +203,14 @@ test("proactively syncs project metadata newest first", async () => {
await wait(() => data.project.get("proj_test") !== undefined)
expect(data.project.list()).toEqual([
{
id: "proj_test",
id: Project.ID.make("proj_test"),
canonical: worktree,
name: "OpenCode",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_old",
id: Project.ID.make("proj_old"),
canonical: "/old/project",
name: "Old project",
time: { created: 1, updated: 1 },
@@ -366,13 +375,20 @@ test("refreshes resources into reactive getters", async () => {
await data.location.websearch.refresh()
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"])
expect(data.session.message.get("ses_test", "msg_second")?.id).toBe("msg_second")
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual([
SessionMessage.ID.make("msg_first"),
SessionMessage.ID.make("msg_second"),
])
expect(data.session.message.get("ses_test", SessionMessage.ID.make("msg_second"))?.id).toBe(
SessionMessage.ID.make("msg_second"),
)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("msg_second")
expect(data.location.default()).toEqual({ directory, workspaceID: undefined })
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual(["build"])
expect(data.location.websearch.list(location)).toEqual([{ id: "standalone", name: "Standalone" }])
expect(data.location.agent.list(location)?.map((agent) => agent.id)).toEqual([Agent.ID.make("build")])
expect(data.location.websearch.list(location)).toEqual([
{ id: WebSearch.ID.make("standalone"), name: "Standalone" },
])
} finally {
app.renderer.destroy()
}
@@ -588,7 +604,9 @@ test("truncates committed revert messages without changing lifetime usage", asyn
})
await wait(() => data.session.message.list(sessionID).length === 1)
expect(data.session.get(sessionID)?.cost).toBe(0.75)
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_revert_boundary"])
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual([
SessionMessage.ID.make("msg_revert_boundary"),
])
expect(data.session.get(sessionID)?.revert).toBeUndefined()
expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
} finally {
@@ -653,17 +671,17 @@ test("updates session location when moved", async () => {
},
})
await wait(() => data.session.get("ses_test")?.location.directory === destination)
expect(data.session.get("ses_test")?.projectID).toBe("project-moved")
expect(data.session.get("ses_test")?.projectID).toBe(Project.ID.make("project-moved"))
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
expect(data.session.message.list("ses_test")).toContainEqual({
id: "msg_moved_1",
id: SessionMessage.ID.make("msg_moved_1"),
type: "location-switched",
location: { directory: destination },
projectID: "project-moved",
projectID: Project.ID.make("project-moved"),
subpath: "packages/cli",
previous: {
location: { directory },
projectID: "proj_test",
projectID: Project.ID.make("proj_test"),
},
time: { created: 1 },
})
@@ -736,6 +754,8 @@ test("restores running manual compaction before applying live deltas", async ()
test("reconnects the event stream and resyncs active data", async () => {
const events = createEventStream()
const staleMessageID = wire<SessionMessage.ID>("message-stale")
const freshMessageID = wire<SessionMessage.ID>("message-fresh")
const requests = { active: 0, event: 0, message: 0, model: 0 }
let resolveActive!: (response: Response) => void
let resolveMessages!: (response: Response) => void
@@ -755,7 +775,7 @@ test("reconnects the event stream and resyncs active data", async () => {
requests.message++
if (requests.message === 1)
return json({
data: [{ id: "message-stale", type: "user", text: "Stale", time: { created: 1 } }],
data: [{ id: staleMessageID, type: "user", text: "Stale", time: { created: 1 } }],
cursor: {},
})
return new Promise<Response>((resolve) => {
@@ -808,7 +828,7 @@ test("reconnects the event stream and resyncs active data", async () => {
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
await wait(() => data.session.status("session-stale") === "running")
await data.session.message.sync("session-stale")
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
expect(data.session.message.get("session-stale", staleMessageID)?.id).toBe(staleMessageID)
expect(client.connection.status()).toBe("connected")
expect(client.connection.attempt()).toBe(0)
@@ -824,15 +844,15 @@ test("reconnects the event stream and resyncs active data", async () => {
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
await wait(() => data.session.status("session-stale") === "idle")
await wait(() => requests.message === 2)
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
expect(data.session.message.get("session-stale", staleMessageID)?.id).toBe(staleMessageID)
resolveMessages(
json({
data: [{ id: "message-fresh", type: "user", text: "Fresh", time: { created: 2 } }],
data: [{ id: freshMessageID, type: "user", text: "Fresh", time: { created: 2 } }],
cursor: {},
}),
)
await wait(() => data.session.message.get("session-stale", "message-fresh") !== undefined)
expect(data.session.message.get("session-stale", "message-stale")).toBeUndefined()
await wait(() => data.session.message.get("session-stale", freshMessageID) !== undefined)
expect(data.session.message.get("session-stale", staleMessageID)).toBeUndefined()
await wait(() => data.session.status("session-new") === "running")
expect(requests.event).toBe(2)
expect(requests.message).toBe(2)
@@ -933,6 +953,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
test("updates and removes queued inputs from durable lifecycle events", async () => {
const events = createEventStream()
const sessionID = "session-queue-management"
const queuedID = wire<SessionMessage.ID>("message-queued")
const cancelledID = wire<SessionMessage.ID>("message-cancelled")
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
@@ -968,40 +990,40 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
durable: durable(sessionID),
data: {
sessionID,
inboxID: "message-queued",
inboxID: queuedID,
item: { type: "user", payload: { text: "Steer me" }, delivery: "queue" },
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
expect(rows).not.toContainEqual({ type: "message", messageID: queuedID })
emitEvent(events, {
id: "evt_queue_steered",
created: 2,
type: "session.inbox.delivery.changed",
durable: durable(sessionID, 1),
data: { sessionID, inboxID: "message-queued", delivery: "steer" },
data: { sessionID, inboxID: queuedID, delivery: "steer" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
.some((item) => item.id === queuedID && item.type !== "compaction" && item.delivery === "steer"),
)
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
expect(rows).toContainEqual({ type: "message", messageID: queuedID })
emitEvent(events, {
id: "evt_queue_restored",
created: 3,
type: "session.inbox.delivery.changed",
durable: durable(sessionID, 2),
data: { sessionID, inboxID: "message-queued", delivery: "queue" },
data: { sessionID, inboxID: queuedID, delivery: "queue" },
})
await wait(() =>
data.session.pending
.list(sessionID)
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
.some((item) => item.id === queuedID && item.type !== "compaction" && item.delivery === "queue"),
)
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
expect(rows).not.toContainEqual({ type: "message", messageID: queuedID })
emitEvent(events, {
id: "evt_cancel_admitted",
@@ -1010,7 +1032,7 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
durable: durable(sessionID, 3),
data: {
sessionID,
inboxID: "message-cancelled",
inboxID: cancelledID,
item: { type: "user", payload: { text: "Delete me" }, delivery: "queue" },
},
})
@@ -1020,11 +1042,11 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
created: 5,
type: "session.inbox.cancelled",
durable: durable(sessionID, 4),
data: { sessionID, inboxID: "message-cancelled" },
data: { sessionID, inboxID: cancelledID },
})
await wait(() => !data.session.input.has(sessionID, "message-cancelled"))
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-queued"])
expect(data.session.message.get(sessionID, "message-cancelled")).toBeUndefined()
await wait(() => !data.session.input.has(sessionID, cancelledID))
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([queuedID])
expect(data.session.message.get(sessionID, cancelledID)).toBeUndefined()
} finally {
app.renderer.destroy()
}
@@ -1125,7 +1147,9 @@ test("removes committed revert messages from local state", async () => {
})
await wait(() => data.session.message.list(sessionID).length === 1)
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual(["msg_001"])
expect(data.session.message.list(sessionID).map((message) => message.id)).toEqual([
SessionMessage.ID.make("msg_001"),
])
expect(data.session.message.get(sessionID, "msg_002")).toBeUndefined()
expect(data.session.message.get(sessionID, "msg_003")).toBeUndefined()
} finally {
@@ -1575,9 +1599,11 @@ test("tracks session status from active sessions and execution events", async ()
test("restores queued compaction from durable pending input", async () => {
const events = createEventStream()
const sessionID = "session-compaction-queued"
let pending = [
const queuedID = wire<SessionMessage.ID>("message-compaction-queued")
const laterID = wire<SessionMessage.ID>("message-compaction-later")
let pending = wire<SessionInboxInfo[]>([
{
id: "message-compaction-queued",
id: queuedID,
sessionID,
timeCreated: 1,
type: "compaction" as const,
@@ -1585,14 +1611,14 @@ test("restores queued compaction from durable pending input", async () => {
delivery: "queue" as const,
},
{
id: "message-compaction-later",
id: laterID,
sessionID,
timeCreated: 2,
type: "compaction" as const,
payload: {},
delivery: "queue" as const,
},
]
])
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${sessionID}/inbox`) return
return json({ data: pending })
@@ -1623,10 +1649,7 @@ test("restores queued compaction from durable pending input", async () => {
try {
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",
])
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([queuedID, laterID])
await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2)
expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([
{ type: "compaction-queued", inboxID: "message-compaction-queued" },
@@ -1680,11 +1703,11 @@ test("restores queued compaction from durable pending input", async () => {
sessionID,
reason: "manual",
recent: "",
inputID: "message-compaction-queued",
inputID: queuedID,
},
})
await wait(() => data.session.pending.list(sessionID).length === 1)
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([laterID])
emitEvent(events, {
id: "evt_compaction_ended",
@@ -1693,7 +1716,7 @@ test("restores queued compaction from durable pending input", async () => {
durable: durable(sessionID, 7),
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
})
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([laterID])
pending = []
data.session.pending.invalidate(sessionID)
@@ -1967,7 +1990,7 @@ test("refreshes references after updates", async () => {
test("keeps shell state scoped to location", async () => {
const events = createEventStream()
const other = "/tmp/opencode/other"
const workspace = "ws_other"
const workspace = wire<Workspace.ID>("ws_other")
let removed: URL | undefined
const calls = createFetch((url, request) => {
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
@@ -2028,8 +2051,10 @@ test("keeps shell state scoped to location", async () => {
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
await data.shell.sync({ directory: other, workspaceID: workspace })
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
expect(data.shell.list().map((shell) => shell.id)).toEqual([Shell.ID.make("sh_default")])
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual([
Shell.ID.make("sh_other"),
])
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
["sh_default", directory],
["sh_other", other],
@@ -2042,28 +2067,30 @@ test("keeps shell state scoped to location", async () => {
expect(removed?.searchParams.get("location[directory]")).toBe(other)
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
events.emit({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other, workspaceID: workspace },
data: {
info: {
id: "sh_live_other",
status: "running",
command: "npm run watch",
cwd: other,
shell: "/bin/sh",
file: "/tmp/opencode-shell-live",
metadata: { sessionID: "ses_shared" },
time: { started: 2 },
events.emit(
wire<OpenCodeEvent>({
id: "evt_shell_created",
created: 0,
type: "shell.created",
location: { directory: other, workspaceID: workspace },
data: {
info: {
id: "sh_live_other",
status: "running",
command: "npm run watch",
cwd: other,
shell: "/bin/sh",
file: "/tmp/opencode-shell-live",
metadata: { sessionID: "ses_shared" },
time: { started: 2 },
},
},
},
})
}),
)
await wait(() =>
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
)
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
expect(data.shell.list().map((shell) => shell.id)).toEqual([Shell.ID.make("sh_default")])
expect(
data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory,
).toBe(other)
@@ -2129,7 +2156,7 @@ test("adds and dismisses permission requests from live events", async () => {
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")
expect(data.session.permission.list("ses_1")?.[0]?.id).toBe(Permission.ID.make("per_2"))
emitEvent(events, {
id: "evt_permission_replied_2",
@@ -2317,7 +2344,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
await wait(() => data.session.form.list("ses_1")?.length === 0)
await data.session.form.sync("ses_1")
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"])
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual([Form.ID.make("frm_remote")])
} finally {
app.renderer.destroy()
}
@@ -2326,7 +2353,10 @@ test("adds, dismisses, and refreshes form requests", async () => {
test("tracks global forms by location", async () => {
const events = createEventStream()
const calls = createFetch(undefined, events)
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
const other = wire<{ directory: string; workspaceID: Workspace.ID }>({
directory: "/tmp/opencode-other",
workspaceID: "wrk_other",
})
let data!: ReturnType<typeof useData>
let client!: ReturnType<typeof useClient>
@@ -2350,39 +2380,47 @@ test("tracks global forms by location", async () => {
try {
await wait(() => client.connection.status() === "connected")
events.emit({
id: "evt_form_created_global_other",
created: 0,
location: other,
type: "form.created",
data: {
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
},
})
events.emit(
wire<OpenCodeEvent>({
id: "evt_form_created_global_other",
created: 0,
location: other,
type: "form.created",
data: {
form: { id: "frm_other", sessionID: "global", title: "Input requested", fields: formFields },
},
}),
)
await wait(() => data.session.form.list("global", other)?.length === 1)
expect(data.session.form.list("global", { directory }) ?? []).toEqual([])
events.emit({
id: "evt_form_created_global_default",
created: 1,
location: { directory },
type: "form.created",
data: {
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
},
})
events.emit(
wire<OpenCodeEvent>({
id: "evt_form_created_global_default",
created: 1,
location: { directory },
type: "form.created",
data: {
form: { id: "frm_default", sessionID: "global", title: "Input requested", fields: formFields },
},
}),
)
await wait(() => data.session.form.list("global", { directory })?.length === 1)
events.emit({
id: "evt_form_replied_global_other",
created: 2,
location: other,
type: "form.replied",
data: { id: "frm_other", sessionID: "global", answer: {} },
})
events.emit(
wire<OpenCodeEvent>({
id: "evt_form_replied_global_other",
created: 2,
location: other,
type: "form.replied",
data: { id: "frm_other", sessionID: "global", answer: {} },
}),
)
await wait(() => data.session.form.list("global", other)?.length === 0)
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual([
Form.ID.make("frm_default"),
])
} finally {
app.renderer.destroy()
}
@@ -2391,7 +2429,10 @@ test("tracks global forms by 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" }
const other = wire<{ directory: string; workspaceID: Workspace.ID }>({
directory: "/tmp/opencode-other",
workspaceID: "wrk_other",
})
const calls = createFetch((url) => {
if (url.pathname !== "/api/form/request") return
requests.push(url)
@@ -2444,8 +2485,10 @@ test("syncs global forms once for each requested location", async () => {
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"])
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual([Form.ID.make("frm_other")])
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual([
Form.ID.make("frm_default"),
])
data.session.form.invalidate("global", other)
await data.session.form.sync("global", other)
@@ -2460,7 +2503,10 @@ test("resyncs global forms only for the active location after reconnect", async
const requests: URL[] = []
const counts = new Map<string, number>()
const home = { directory: process.cwd() }
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
const other = wire<{ directory: string; workspaceID: Workspace.ID }>({
directory: "/tmp/opencode-other",
workspaceID: "wrk_other",
})
const calls = createFetch((url) => {
if (url.pathname === "/api/location")
return json({ ...home, project: { id: "proj_test", directory: home.directory } })
@@ -2517,14 +2563,14 @@ test("resyncs global forms only for the active location after reconnect", async
try {
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(data.session.form.list("global", other)?.[0]?.id).toBe(Form.ID.make("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", 4000)
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
expect(data.session.form.list("global", other)?.[0]?.id).toBe(Form.ID.make("frm_other_1"))
expect(requests).toHaveLength(1)
expect(
requests.map((url) => [
@@ -2734,7 +2780,7 @@ test("settles pending tools when a live failure arrives", async () => {
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9")
expect(assistant.id).toBe(SessionMessage.ID.make("msg_explicit_assistant_9"))
const tool = assistant.content[0]
expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return
@@ -2764,8 +2810,8 @@ test("settles pending tools when a live failure arrives", async () => {
test("renders admitted prompts immediately and tracks them until promoted", async () => {
const events = createEventStream()
const sessionID = "session-1"
const messageID = "msg_user_1"
const sessionID = Session.ID.make("session-1")
const messageID = SessionMessage.ID.make("msg_user_1")
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`)
return json({
@@ -2997,9 +3043,9 @@ test("syncs direct child session info with a navigated root", async () => {
const { data, app } = await mountData({ child: "root", sibling: "root", grandchild: "child" })
try {
await data.session.sync("root", { children: true })
expect(data.session.get("root")?.id).toBe("root")
expect(data.session.get("child")?.parentID).toBe("root")
expect(data.session.get("sibling")?.parentID).toBe("root")
expect(data.session.get("root")?.id).toBe(wire<Session.ID>("root"))
expect(data.session.get("child")?.parentID).toBe(wire<Session.ID>("root"))
expect(data.session.get("sibling")?.parentID).toBe(wire<Session.ID>("root"))
expect(data.session.get("grandchild")).toBeUndefined()
expect(data.session.family("root")).toEqual(["root", "child", "sibling"])
} finally {
@@ -3097,7 +3143,7 @@ test("stops at the last non-repeating ancestor on a parent cycle", async () => {
test("admits prompts optimistically and reconciles with the durable echo", async () => {
const events = createEventStream()
const sessionID = "session-1"
const sessionID = Session.ID.make("session-1")
let release!: (response: Response) => void
const deferred = new Promise<Response>((resolve) => {
release = resolve
@@ -3199,14 +3245,14 @@ test("admits prompts optimistically and reconciles with the durable echo", async
test("hydrates durable pending prompts into the visible transcript", async () => {
const sessionID = "session-1"
const item = {
const item = wire<SessionInboxInfo>({
id: "msg_pending_1",
sessionID,
timeCreated: 5,
type: "user" as const,
payload: { text: "waiting" },
delivery: "steer" as const,
}
})
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/inbox`) return json({ data: [item] })
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
@@ -3252,7 +3298,7 @@ test("hydrates durable pending prompts into the visible transcript", async () =>
test("keeps the row when the response lands before the echo", async () => {
const events = createEventStream()
const sessionID = "session-1"
const messageID = "msg_early_1"
const messageID = SessionMessage.ID.make("msg_early_1")
const admission = {
id: messageID,
sessionID,
@@ -3359,7 +3405,7 @@ test("rolls back an optimistic prompt the server rejected", async () => {
test("a retry under the same client-minted ID cannot duplicate rows", async () => {
const events = createEventStream()
const sessionID = "session-1"
const messageID = "msg_retry_1"
const messageID = SessionMessage.ID.make("msg_retry_1")
const admission = {
id: messageID,
sessionID,
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
@@ -20,9 +21,10 @@ import { emptyThemeSource } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { wire } from "../../fixture/wire"
test("selecting an unhydrated session preserves its location", async () => {
const remote = { directory: "/tmp/opencode/remote", workspaceID: "ws_remote" }
const remote = wire<SessionInfo["location"]>({ directory: "/tmp/opencode/remote", workspaceID: "ws_remote" })
const fixture = await renderOpen((url) => {
if (url.pathname !== "/api/session") return undefined
return json({
@@ -57,7 +59,7 @@ test("selecting an unhydrated session preserves its location", async () => {
test("finds and opens an exact session ID outside the recent list", async () => {
const sessionID = "ses_04a7a3d82ffeIphUJgd3SnEqiv"
const remote = { directory: "/tmp/opencode/archive", workspaceID: "ws_archive" }
const remote = wire<SessionInfo["location"]>({ directory: "/tmp/opencode/archive", workspaceID: "ws_archive" })
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== `/api/session/${sessionID}`) return undefined
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogSessionList } from "../../../src/component/dialog-session-list"
@@ -22,6 +23,7 @@ import { createApi, createEventStream, createFetch, json } from "../../fixture/t
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { wire } from "../../fixture/wire"
test("scopes sessions to the active session location", async () => {
const active = "/tmp/opencode/project-b"
@@ -60,15 +62,17 @@ test("scopes sessions to the active session location", async () => {
const route = useRoute()
storage = useStorage()
onMount(() => {
data.session.remember({
id: "ses_active",
projectID: "proj_b",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 3 },
title: "Active session",
location: { directory: active },
})
data.session.remember(
wire<SessionInfo>({
id: "ses_active",
projectID: "proj_b",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 3 },
title: "Active session",
location: { directory: active },
}),
)
route.navigate({ type: "session", sessionID: "ses_active" })
dialog.replace(() => <DialogSessionList />)
})
+3 -2
View File
@@ -13,6 +13,7 @@ import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { wire } from "../../fixture/wire"
async function mountForm(
root: string,
@@ -38,7 +39,7 @@ async function mountForm(
events,
)
const config = createTuiResolvedConfig()
const form = {
const form = wire<FormWithLocation>({
id: "frm_test",
sessionID: "ses_test",
title: "Authorization required",
@@ -50,7 +51,7 @@ async function mountForm(
title: "Authorize access",
},
],
} satisfies FormWithLocation
})
const { FormPrompt } = await import("../../../src/routes/session/form")
function Harness() {
@@ -1,12 +1,13 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
import { wire } from "../../fixture/wire"
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
assistant("assistant-1", "Response"),
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
]
])
const children = [
{ id: "user-1", y: 0 },
{ id: "assistant-1", y: 20 },
@@ -153,12 +154,12 @@ test("stops at the first and last message", () => {
})
function assistant(id: string, text: string): SessionMessageAssistant {
return {
return wire<SessionMessageAssistant>({
type: "assistant",
id,
agent: "build",
model: { providerID: "test", id: "test" },
content: [{ type: "text", text }],
time: { created: 1, completed: 1 },
}
})
}
+54 -46
View File
@@ -1,23 +1,24 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
import { wire, type Wire } from "../../fixture/wire"
test("measures turn duration from the user prompt across assistant steps", () => {
const first = assistant("assistant-1", [])
first.time = { created: 8_000, completed: 11_000 }
const final = assistant("assistant-2", [])
final.time = { created: 27_000, completed: 30_000 }
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
first,
final,
]
])
expect(turnDuration(final, messages)).toBe(29_000)
})
test("filters OpenAI cache quantization from cache reuse drops", () => {
const openai = { id: "gpt", providerID: "openai" }
const openai = model("gpt", "openai")
expect(cacheReuseDrop(undefined, { read: 10_000, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 11_000, model: openai })).toBeUndefined()
expect(cacheReuseDrop({ read: 10_000, model: openai }, { read: 8_977, model: openai })).toBe(1_023)
@@ -28,34 +29,34 @@ test("filters OpenAI cache quantization from cache reuse drops", () => {
})
test("compares cache reuse only for the same model", () => {
const previous = { read: 10_000, model: { id: "claude", providerID: "anthropic" } }
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "gpt", providerID: "openai" } })).toBeUndefined()
expect(cacheReuseDrop(previous, { read: 8_976, model: { id: "claude", providerID: "anthropic" } })).toBe(1_024)
const previous = { read: 10_000, model: model("claude", "anthropic") }
expect(cacheReuseDrop(previous, { read: 8_976, model: model("gpt", "openai") })).toBeUndefined()
expect(cacheReuseDrop(previous, { read: 8_976, model: model("claude", "anthropic") })).toBe(1_024)
expect(
cacheReuseDrop(
{ read: 10_000, model: { id: "gpt", providerID: "openai", variant: "low" } },
{ read: 8_976, model: { id: "gpt", providerID: "openai", variant: "high" } },
{ read: 10_000, model: model("gpt", "openai", "low") },
{ read: 8_976, model: model("gpt", "openai", "high") },
),
).toBeUndefined()
})
test("carries model identity with the cross-turn cache baseline", () => {
const first = assistant("assistant-1", [])
first.model = { id: "claude", providerID: "anthropic" }
first.model = model("claude", "anthropic")
first.finish = "stop"
first.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 10_000, write: 0 } }
const second = assistant("assistant-2", [])
second.model = { id: "gpt", providerID: "openai" }
second.model = model("gpt", "openai")
second.finish = "stop"
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 8_976, write: 0 } }
const rows = reduceSessionRows(
[
wire<SessionMessageInfo[]>([
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
first,
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
second,
],
]),
new Set(),
true,
).filter((row) => row.type === "turn-usage")
@@ -65,7 +66,7 @@ test("carries model identity with the cross-turn cache baseline", () => {
{
type: "turn-usage",
messageIDs: ["assistant-2"],
previousCache: { read: 10_000, model: { id: "claude", providerID: "anthropic" } },
previousCache: { read: 10_000, model: model("claude", "anthropic") },
},
])
})
@@ -79,7 +80,7 @@ test("resets the cross-turn cache baseline after compaction", () => {
second.tokens = { input: 1, output: 0, reasoning: 0, cache: { read: 13_824, write: 0 } }
const rows = reduceSessionRows(
[
wire<SessionMessageInfo[]>([
first,
{
type: "compaction",
@@ -91,7 +92,7 @@ test("resets the cross-turn cache baseline after compaction", () => {
time: { created: 2 },
},
second,
],
]),
new Set(),
true,
).filter((row) => row.type === "turn-usage")
@@ -103,21 +104,23 @@ test("resets the cross-turn cache baseline after compaction", () => {
})
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
assistant("assistant-1", [
{ type: "reasoning", text: "Thinking" },
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
]),
]
])
const rows = reduceSessionRows(messages)
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
expect(messageBoundaryIDs(rows, messages)).toEqual(
wire<ReturnType<typeof messageBoundaryIDs>>(["user-1", "assistant-1", undefined, undefined]),
)
})
test("groups exploration parts across assistant messages until a delimiter", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
assistant("assistant-1", [
{ type: "text", text: "Looking" },
@@ -128,7 +131,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 5 } },
{ type: "text", text: "Done" },
]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{ type: "message", messageID: "user-1" },
@@ -149,13 +152,13 @@ test("groups exploration parts across assistant messages until a delimiter", ()
})
test("keeps non-exploration tools as individual part rows", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
assistant("assistant-1", [
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
{ type: "tool", id: "reasoning:0", name: "bash", state: pending(), time: { created: 2 } },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{
@@ -177,14 +180,14 @@ test("keeps non-exploration tools as individual part rows", () => {
})
test("assigns stable kind ordinals within an assistant message", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
assistant("assistant-1", [
{ type: "text", text: "First" },
{ type: "reasoning", text: "Think" },
{ type: "text", text: "Second" },
{ type: "reasoning", text: "Check" },
]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
@@ -205,14 +208,14 @@ test("assigns stable kind ordinals within an assistant message", () => {
})
test("groups adjacent reasoning parts until a visible boundary", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
assistant("assistant-1", [
{ type: "reasoning", text: "First" },
{ type: "reasoning", text: "Second" },
{ type: "text", text: "Visible" },
{ type: "reasoning", text: "Third" },
]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{
@@ -235,7 +238,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
})
test("groups across empty assistant reasoning parts", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
assistant("assistant-1", [
{ type: "reasoning", text: "Looking" },
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
@@ -244,7 +247,7 @@ test("groups across empty assistant reasoning parts", () => {
{ type: "reasoning", text: "" },
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{
@@ -271,11 +274,11 @@ test("completes exploration groups when another row follows", () => {
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
])
finished.finish = "stop"
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
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([
{
@@ -298,7 +301,7 @@ test("completes exploration groups when another row follows", () => {
})
test("hides synthetic messages without descriptions", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
@@ -307,7 +310,7 @@ test("hides synthetic messages without descriptions", () => {
time: { created: 2 },
},
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{
@@ -324,7 +327,7 @@ test("hides synthetic messages without descriptions", () => {
})
test("renders synthetic messages with descriptions", () => {
const messages: SessionMessageInfo[] = [
const messages = wire<SessionMessageInfo[]>([
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
{
type: "synthetic",
@@ -334,7 +337,7 @@ test("renders synthetic messages with descriptions", () => {
time: { created: 2 },
},
assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]),
]
])
expect(reduceSessionRows(messages)).toEqual([
{
@@ -367,13 +370,14 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
})
test("places a running compaction barrier before every queued user message", () => {
const queued = (id: string, text: string, created: number): SessionMessageInfo => ({
type: "user",
id,
text,
time: { created },
})
const messages: SessionMessageInfo[] = [
const queued = (id: string, text: string, created: number) =>
wire<SessionMessageInfo>({
type: "user",
id,
text,
time: { created },
})
const messages = wire<SessionMessageInfo[]>([
queued("user-before", "Before", 1),
{
type: "compaction",
@@ -385,7 +389,7 @@ test("places a running compaction barrier before every queued user message", ()
time: { created: 2 },
},
queued("user-after", "After", 3),
]
])
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
{ type: "message", messageID: "compaction" },
@@ -394,15 +398,19 @@ test("places a running compaction barrier before every queued user message", ()
])
})
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
function assistant(id: string, content: Wire<SessionMessageAssistant["content"]>): SessionMessageAssistant {
return wire<SessionMessageAssistant>({
type: "assistant",
id,
agent: "build",
model: { id: "model", providerID: "provider" },
model: model("model", "provider"),
content,
time: { created: 1 },
}
})
}
function model(id: string, providerID: string, variant?: string): SessionMessageAssistant["model"] {
return wire<SessionMessageAssistant["model"]>({ id, providerID, ...(variant ? { variant } : {}) })
}
function pending() {
+8 -7
View File
@@ -8,6 +8,7 @@ import { useEvent } from "../../../src/context/event"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import type { LogLevel, LogSink } from "../../../src/context/log"
import { wire, type Wire } from "../../fixture/wire"
const projectID = "proj_test"
@@ -20,35 +21,35 @@ async function wait(fn: () => boolean, timeout = 2000) {
}
function event(
payload: OpenCodeEvent,
payload: Wire<OpenCodeEvent>,
input: { directory: string; project?: string; workspace?: string },
): OpenCodeEvent {
return {
return wire<OpenCodeEvent>({
...payload,
location: { directory: input.directory, workspaceID: input.workspace },
}
})
}
function vcs(branch: string): OpenCodeEvent {
return {
return wire<OpenCodeEvent>({
id: `evt_vcs_${branch}`,
created: 0,
type: "vcs.branch.updated",
data: {
branch,
},
}
})
}
function update(version: string): OpenCodeEvent {
return {
return wire<OpenCodeEvent>({
id: `evt_update_${version}`,
created: 0,
type: "installation.update-available",
data: {
version,
},
}
})
}
async function mount(reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, log?: LogSink) {
@@ -17,6 +17,7 @@ import { createApi, createEventStream, createFetch, directory, json } from "../f
import { TestTuiContexts } from "../fixture/tui-environment"
import { tmpdir } from "../fixture/fixture"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
import { wire, type Wire } from "../fixture/wire"
async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000, label = "condition") {
const start = Date.now()
@@ -141,7 +142,7 @@ async function renderSessionTabs(
locations,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
emit: (event: Wire<OpenCodeEvent>) => events.emit(wire<OpenCodeEvent>({ ...event, location: { directory } })),
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
flush: () => storage.flush(),
@@ -153,7 +154,7 @@ async function renderSessionTabs(
}
}
const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
const executionSucceeded = (sessionID: string): Wire<OpenCodeEvent> => ({
id: `evt_done_${sessionID}`,
created: Date.now(),
type: "session.execution.succeeded",
@@ -347,7 +348,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inboxID: string): OpenCodeEvent => ({
const admitted = (sessionID: string, inboxID: string): Wire<OpenCodeEvent> => ({
id: `evt_${inboxID}`,
created: Date.now(),
type: "session.inbox.enqueued",
+14
View File
@@ -0,0 +1,14 @@
import type { Brand } from "effect"
export type Wire<T> =
T extends Brand.Brand<string>
? Brand.Brand.Unbranded<T>
: T extends ReadonlyArray<infer Item>
? Wire<Item>[]
: T extends object
? { [Key in keyof T]: Wire<T[Key]> }
: T
export function wire<T>(value: Wire<T>): T {
return value as T
}
+5 -4
View File
@@ -1,13 +1,14 @@
import { spyOn } from "bun:test"
import type { LocationRef, ModelListOutput, OpenCodeClient, ProviderListOutput } from "@opencode-ai/client/promise"
import { wire } from "../../fixture/wire"
export function catalogProvider(id: string, name: string): ProviderListOutput["data"][number] {
return {
return wire<ProviderListOutput["data"][number]>({
id,
name,
activation: "auto",
package: "",
}
})
}
export function catalogModel(input: {
@@ -18,7 +19,7 @@ export function catalogModel(input: {
context?: number
variants?: string[]
}): ModelListOutput["data"][number] {
return {
return wire<ModelListOutput["data"][number]>({
id: input.id,
modelID: input.modelID ?? input.id,
providerID: input.providerID,
@@ -34,7 +35,7 @@ export function catalogModel(input: {
status: "active",
enabled: true,
limit: { context: input.context ?? 128_000, output: 8_192 },
}
})
}
export function stubCatalogLists(
+14 -11
View File
@@ -39,6 +39,7 @@ import type {
import { selectedCommand } from "../../src/mini/footer.prompt"
import { RejectField } from "../../src/mini/footer.permission"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
import { wire } from "../fixture/wire"
const tuiConfig = createTuiResolvedConfig()
@@ -228,7 +229,7 @@ test("direct footer shows the default model without the fallback agent", async (
})
test("direct footer preserves a partial multi-field form draft across permission preemption", async () => {
const request: FormInfo = {
const request = wire<FormInfo>({
id: "frm_preempted",
sessionID: "ses_child",
title: "Deployment",
@@ -236,7 +237,7 @@ test("direct footer preserves a partial multi-field form draft across permission
{ key: "service", type: "string", title: "Service", required: true },
{ key: "notes", type: "string", title: "Notes", required: true },
],
}
})
const app = await renderFooter({
height: 16,
view: { type: "form", request },
@@ -250,15 +251,17 @@ test("direct footer preserves a partial multi-field form draft across permission
"keep this draft".split("").forEach((key) => app.mockInput.pressKey(key))
expect(app.renderer.currentFocusedEditor?.plainText).toBe("keep this draft")
app.setView({
type: "permission",
request: {
id: "per_preempting",
sessionID: "ses_child",
action: "read",
resources: ["src/index.ts"],
},
})
app.setView(
wire<FooterView>({
type: "permission",
request: {
id: "per_preempting",
sessionID: "ses_child",
action: "read",
resources: ["src/index.ts"],
},
}),
)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Permission required")
+13 -7
View File
@@ -13,9 +13,11 @@ import {
formUnsupported,
formValidate,
} from "../../src/mini/form.shared"
import type { FormReply, MiniFormRequest } from "../../src/mini/types"
import { wire } from "../fixture/wire"
function request(fields: FormField[]): FormInfo {
return { id: "frm_1", sessionID: "ses_1", title: "Input", fields: fields as FormInfo["fields"] }
return wire<FormInfo>({ id: "frm_1", sessionID: "ses_1", title: "Input", fields })
}
describe("Mini form state", () => {
@@ -39,12 +41,16 @@ describe("Mini form state", () => {
const answer = { choice: "fast", count: 1.5, whole: 2, enabled: false, tags: ["custom"], external: true }
expect(formAnswer(form, state)).toEqual(answer)
expect(formReply({ ...form, location: { directory: "/tmp", workspaceID: "wrk_1" } }, state)).toEqual({
sessionID: "ses_1",
formID: "frm_1",
answer,
location: { directory: "/tmp", workspaceID: "wrk_1" },
})
expect(
formReply(wire<MiniFormRequest>({ ...form, location: { directory: "/tmp", workspaceID: "wrk_1" } }), state),
).toEqual(
wire<FormReply>({
sessionID: "ses_1",
formID: "frm_1",
answer,
location: { directory: "/tmp", workspaceID: "wrk_1" },
}),
)
})
test("rejects invalid and deliberately unsupported shapes", () => {
@@ -9,10 +9,11 @@ import {
permissionRun,
} from "../../src/mini/permission.shared"
import type { MiniPermissionRequest } from "../../src/mini/types"
import { wire } from "../fixture/wire"
import { canonicalToolPart } from "./fixture/tool-part"
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
return {
return wire<MiniPermissionRequest>({
id: "perm-1",
sessionID: "session-1",
action: "read",
@@ -20,7 +21,7 @@ function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest
metadata: {},
save: [],
...input,
}
})
}
function body() {
+65 -52
View File
@@ -1,8 +1,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 { OpenCode, type LocationRef } from "@opencode-ai/client/promise"
import { runInteractiveDeferredMode, type RunDeferredInput } from "../../src/mini/runtime"
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
import type { FooterEvent, MiniHost } from "../../src/mini/types"
import type { FooterEvent, FormReply, MiniHost } from "../../src/mini/types"
import { wire, type Wire } from "../fixture/wire"
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
import { createFooterApiFixture } from "./fixture/footer-api"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
@@ -19,6 +20,10 @@ function ok<T>(data: T) {
return Promise.resolve(data)
}
function resolvedSession(value: Wire<Awaited<ReturnType<RunDeferredInput["target"]>>>) {
return wire<Awaited<ReturnType<RunDeferredInput["target"]>>>(value)
}
function host(): MiniHost {
return {
terminal: { stdin: process.stdin },
@@ -90,14 +95,15 @@ describe("run interactive runtime", () => {
host: host(),
sdk,
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
resume: false,
}),
target: async () =>
resolvedSession({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
resume: false,
}),
agent: "build",
model: undefined,
variant: undefined,
@@ -144,10 +150,12 @@ describe("run interactive runtime", () => {
expect(events.some((event) => event.type === "model")).toBe(false)
await refreshCatalog?.()
expect(defaultModel).toHaveBeenCalledTimes(1)
selected.resolve({
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
})
selected.resolve(
wire<Awaited<ReturnType<typeof sdk.model.default>>>({
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
data: model,
}),
)
await defaultModelReloaded.promise
await modelShown.promise
expect(events).toContainEqual({
@@ -179,14 +187,15 @@ describe("run interactive runtime", () => {
host: host(),
sdk,
directory: "/tmp",
target: async () => ({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: { providerID: "test", modelID: "model" },
variant: undefined,
resume: false,
}),
target: async () =>
resolvedSession({
sessionID: "ses_root",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: { providerID: "test", modelID: "model" },
variant: undefined,
resume: false,
}),
agent: "build",
model: { providerID: "test", modelID: "model" },
variant: undefined,
@@ -225,12 +234,14 @@ describe("run interactive runtime", () => {
)
await streamStarted.promise
await lifecycle.onFormReply({
sessionID: "global",
formID: "frm_global",
answer: { value: "yes" },
location: { directory: "/remote work", workspaceID: "wrk_1" },
})
await lifecycle.onFormReply(
wire<FormReply>({
sessionID: "global",
formID: "frm_global",
answer: { value: "yes" },
location: { directory: "/remote work", workspaceID: "wrk_1" },
}),
)
expect(reply).toHaveBeenCalledWith(
{
sessionID: "global",
@@ -274,7 +285,7 @@ describe("run interactive runtime", () => {
target: async () => {
resolved++
api.close()
return {
return resolvedSession({
sessionID: "ses-deferred",
sessionTitle: "Deferred",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
@@ -282,7 +293,7 @@ describe("run interactive runtime", () => {
model: { providerID: "openai", modelID: "gpt-5" },
variant: undefined,
resume: false,
}
})
},
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
@@ -362,15 +373,16 @@ describe("run interactive runtime", () => {
host: host(),
sdk,
directory: "/tmp",
target: async () => ({
sessionID: "ses-resume",
sessionTitle: "Resume",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "review",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "high",
resume: true,
}),
target: async () =>
resolvedSession({
sessionID: "ses-resume",
sessionTitle: "Resume",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "review",
model: { providerID: "openai", modelID: "gpt-5" },
variant: "high",
resume: true,
}),
agent: "build",
model: undefined,
variant: undefined,
@@ -445,15 +457,16 @@ describe("run interactive runtime", () => {
host: host(),
sdk,
directory: "/tmp",
target: async () => ({
sessionID: "ses-resume-abort",
sessionTitle: "Cached title",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
resume: true,
}),
target: async () =>
resolvedSession({
sessionID: "ses-resume-abort",
sessionTitle: "Cached title",
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
agent: "build",
model: undefined,
variant: undefined,
resume: true,
}),
agent: "build",
model: undefined,
variant: undefined,
@@ -502,7 +515,7 @@ describe("run interactive runtime", () => {
let runtimeConfig: LifecycleInput["tuiConfig"] | undefined
const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } })
const catalogs = stubCatalogLists(sdk, {
location: { directory: "/session", workspaceID: "work-1" },
location: wire<LocationRef>({ directory: "/session", workspaceID: "work-1" }),
})
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
location: {
@@ -520,7 +533,7 @@ describe("run interactive runtime", () => {
directory: "/launch",
target: async () => {
targets++
return {
return resolvedSession({
sessionID: "ses-target",
location: {
directory: "/session",
@@ -531,7 +544,7 @@ describe("run interactive runtime", () => {
model: { providerID: "openai", modelID: "gpt-5" },
variant: "high",
resume: false,
}
})
},
agent: undefined,
model: undefined,
+38 -33
View File
@@ -8,6 +8,7 @@ import {
type RunSession,
type SessionMessages,
} from "../../src/mini/session.shared"
import { wire } from "../fixture/wire"
const model = {
providerID: "openai",
@@ -19,13 +20,13 @@ afterEach(() => {
})
function userMessage(id: string, text: string, input: Partial<SessionMessageUser> = {}): SessionMessageUser {
return {
return wire<SessionMessageUser>({
id,
type: "user",
text,
time: { created: 1 },
...input,
}
})
}
describe("run session shared", () => {
@@ -158,39 +159,43 @@ describe("run session shared", () => {
test("restores current prompt history from stored text and file references", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.message, "list").mockImplementation(() =>
Promise.resolve({
data: [
{
id: "msg_prompt",
type: "user",
text: "Review @note.ts",
files: [
{
data: "",
mime: "text/plain",
name: "note.ts",
source: { type: "uri", uri: "file:///tmp/note.ts" },
mention: { start: 7, end: 15, text: "@note.ts" },
},
],
agents: [],
time: { created: 1 },
},
],
cursor: {},
}),
Promise.resolve(
wire<Awaited<ReturnType<typeof client.message.list>>>({
data: [
{
id: "msg_prompt",
type: "user",
text: "Review @note.ts",
files: [
{
data: "",
mime: "text/plain",
name: "note.ts",
source: { type: "uri", uri: "file:///tmp/note.ts" },
mention: { start: 7, end: 15, text: "@note.ts" },
},
],
agents: [],
time: { created: 1 },
},
],
cursor: {},
}),
),
)
spyOn(client.session, "get").mockImplementation(() =>
Promise.resolve({
id: "ses_1",
title: "Session",
projectID: "proj_1",
location: { directory: "/tmp" },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
}),
Promise.resolve(
wire<Awaited<ReturnType<typeof client.session.get>>>({
id: "ses_1",
title: "Session",
projectID: "proj_1",
location: { directory: "/tmp" },
time: { created: 1, updated: 1 },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
model: { providerID: "openai", id: "gpt-5", variant: "high" },
}),
),
)
const controller = new AbortController()
+264 -277
View File
@@ -12,10 +12,11 @@ import {
} from "@opencode-ai/client/promise"
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
import { entryBody } from "../../src/mini/entry.body"
import type { StreamCommit } from "../../src/mini/types"
import type { FooterEvent, StreamCommit } from "../../src/mini/types"
import { createFooterApiFixture } from "./fixture/footer-api"
import { canonicalToolPart } from "./fixture/tool-part"
import { tmpdir } from "../fixture/fixture"
import { wire, type Wire } from "../fixture/wire"
type RunV2Event = EventSubscribeOutput
@@ -37,8 +38,8 @@ function feed() {
})()
return {
stream,
push(value: RunV2Event) {
values.push(value)
push(value: Wire<RunV2Event>) {
values.push(wire<RunV2Event>(value))
wake?.()
wake = undefined
},
@@ -63,7 +64,7 @@ function defer<T = void>() {
}
function connected(id = "evt_connected") {
return { id, type: "server.connected", data: {} } satisfies RunV2Event
return wire<RunV2Event>({ id, type: "server.connected", data: {} })
}
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
@@ -77,19 +78,17 @@ function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) {
}
function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[0], sessionID = "ses_1") {
return {
return wire<Awaited<ReturnType<OpenCodeClient["session"]["prompt"]>>>({
id: input.id ?? "msg_prompt",
sessionID,
type: "user" as const,
payload: {
text: input.text,
files: input.files,
agents: input.agents,
metadata: input.metadata,
},
delivery: input.delivery ?? ("steer" as const),
timeCreated: 2,
}
})
}
function footer() {
@@ -98,6 +97,14 @@ function footer() {
type SessionMessages = MessageListOutput["data"]
function messageList(data: Wire<SessionMessages>) {
return ok(wire<MessageListOutput>({ data, cursor: {} }))
}
function location(directory: string, workspaceID: string) {
return wire<NonNullable<Parameters<typeof createSessionTransport>[0]["location"]>>({ directory, workspaceID })
}
function compaction(status: "running" | "completed", summary: string): SessionMessages[number] {
const message = {
id: "msg_compaction",
@@ -107,12 +114,12 @@ function compaction(status: "running" | "completed", summary: string): SessionMe
recent: "",
time: { created: 1 },
}
if (status === "running") return { ...message, status }
return { ...message, status }
if (status === "running") return wire<SessionMessages[number]>({ ...message, status })
return wire<SessionMessages[number]>({ ...message, status })
}
function form(id: string, sessionID: string, title = id): FormInfo {
return {
return wire<FormInfo>({
id,
sessionID,
title,
@@ -124,7 +131,7 @@ function form(id: string, sessionID: string, title = id): FormInfo {
custom: true,
},
],
}
})
}
function eventForm(info: FormInfo): Extract<RunV2Event, { type: "form.created" }>["data"]["form"] {
@@ -134,55 +141,67 @@ function eventForm(info: FormInfo): Extract<RunV2Event, { type: "form.created" }
function sdk(input: {
streams: ReturnType<typeof feed>[]
active?: () => Record<string, { type: "running" }>
messages?: Record<string, SessionMessages>
messages?: Record<string, Wire<SessionMessages>>
sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }>
forms?: Record<string, FormInfo[]>
globals?: FormInfo[]
forms?: Record<string, Array<Wire<FormInfo>>>
globals?: Array<Wire<FormInfo>>
globalLocation?: { directory: string; workspaceID?: string }
permissions?: Record<string, PermissionRequest[]>
pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>
permissions?: Record<string, Array<Wire<PermissionRequest>>>
pending?: Record<string, Wire<Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>>
wait?: () => Promise<void>
}) {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
let subscription = 0
spyOn(client.event, "subscribe").mockImplementation(() => input.streams[subscription++]?.stream ?? feed().stream)
spyOn(client.message, "list").mockImplementation((request) =>
ok({
data: input.messages?.[request.sessionID] ?? [
{
id: "msg_old",
type: "user" as const,
text: "previous prompt",
files: [],
agents: [],
time: { created: 1 },
},
],
cursor: {},
}),
ok(
wire<MessageListOutput>({
data: input.messages?.[request.sessionID] ?? [
{
id: "msg_old",
type: "user" as const,
text: "previous prompt",
files: [],
agents: [],
time: { created: 1 },
},
],
cursor: {},
}),
),
)
spyOn(client.permission, "list").mockImplementation((request) =>
ok(wire<PermissionRequest[]>(input.permissions?.[request.sessionID] ?? [])),
)
spyOn(client.form, "list").mockImplementation((request) =>
ok(wire<FormInfo[]>(input.forms?.[request.sessionID] ?? [])),
)
spyOn(client.permission, "list").mockImplementation((request) => ok(input.permissions?.[request.sessionID] ?? []))
spyOn(client.form, "list").mockImplementation((request) => ok(input.forms?.[request.sessionID] ?? []))
spyOn(client.form.request, "list").mockImplementation(() =>
ok({
location: {
directory: input.globalLocation?.directory ?? "/tmp",
workspaceID: input.globalLocation?.workspaceID,
project: {
id: "proj_1",
ok(
wire<Awaited<ReturnType<OpenCodeClient["form"]["request"]["list"]>>>({
location: {
directory: input.globalLocation?.directory ?? "/tmp",
canonical: input.globalLocation?.directory ?? "/tmp",
workspaceID: input.globalLocation?.workspaceID,
project: {
id: "proj_1",
directory: input.globalLocation?.directory ?? "/tmp",
canonical: input.globalLocation?.directory ?? "/tmp",
},
},
},
data: input.globals ?? [],
}),
data: input.globals ?? [],
}),
),
)
spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
spyOn(client.session.inbox, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
spyOn(client.session.inbox, "list").mockImplementation((request) =>
ok(wire<Awaited<ReturnType<OpenCodeClient["session"]["inbox"]["list"]>>>(input.pending?.[request.sessionID] ?? [])),
)
spyOn(client.session, "wait").mockImplementation(() => input.wait?.() ?? ok(undefined))
spyOn(client.session, "message").mockImplementation((request) => {
const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
return message ? (ok(message) as never) : Promise.reject(new Error(`message not found: ${request.messageID}`))
return message
? (ok(wire<SessionMessages[number]>(message)) as never)
: Promise.reject(new Error(`message not found: ${request.messageID}`))
})
spyOn(client.session, "switchAgent").mockImplementation(() => ok(undefined))
spyOn(client.session, "switchModel").mockImplementation(() => ok(undefined))
@@ -374,7 +393,7 @@ describe("V2 mini transport", () => {
const snapshots = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : []))
expect(snapshots.at(-1)?.tabs.map((item) => item.sessionID)).toEqual(["ses_child", "ses_grandchild"])
expect(snapshots.at(-1)?.forms.map((item) => item.id)).toEqual(["frm_child", "frm_grandchild"])
expect(snapshots.at(-1)?.forms.map((item) => String(item.id))).toEqual(["frm_child", "frm_grandchild"])
expect(
ui.events.find(
(event) => event.type === "stream.view" && event.view.type === "form" && event.view.request.id === "frm_child",
@@ -394,7 +413,7 @@ describe("V2 mini transport", () => {
test("resolves a pre-existing child permission from its exact source message at startup", async () => {
const events = feed()
events.push(connected())
const sourceMessage = {
const sourceMessage = wire<SessionMessages[number]>({
id: "msg_child_source",
type: "assistant" as const,
agent: "build",
@@ -411,14 +430,14 @@ describe("V2 mini transport", () => {
),
],
time: { created: 1 },
}
const permission: PermissionRequest = {
})
const permission = wire<PermissionRequest>({
id: "per_child_startup",
sessionID: "ses_child",
action: "shell",
resources: ["git status --short"],
source: { type: "tool", messageID: "msg_child_source", id: "call_child_source" },
}
})
const client = sdk({
streams: [events],
sessions: [{ id: "ses_child", parentID: "ses_1", title: "Child", time: { updated: 1 } }],
@@ -497,7 +516,7 @@ describe("V2 mini transport", () => {
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
location: { directory: "/work", workspaceID: "wrk_1" },
location: location("/work", "wrk_1"),
sessionID: "ses_1",
thinking: false,
footer: ui.api,
@@ -574,7 +593,7 @@ describe("V2 mini transport", () => {
const events = feed()
events.push(connected())
const settled = defer()
const messages: SessionMessages = []
const messages: Wire<SessionMessages> = []
const client = sdk({
streams: [events],
messages: { ses_1: messages },
@@ -824,7 +843,7 @@ describe("V2 mini transport", () => {
const events = feed()
events.push(connected())
const idle = defer()
const messages: SessionMessages = []
const messages: Wire<SessionMessages> = []
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
const ui = footer()
const transport = await createSessionTransport({
@@ -1168,18 +1187,20 @@ describe("V2 mini transport", () => {
})
await Bun.sleep(0)
expect(ui.events).toContainEqual({
type: "stream.view",
view: {
type: "permission",
request: {
id: "per_1",
sessionID: "ses_1",
action: "read",
resources: ["/tmp/file"],
expect(ui.events).toContainEqual(
wire<FooterEvent>({
type: "stream.view",
view: {
type: "permission",
request: {
id: "per_1",
sessionID: "ses_1",
action: "read",
resources: ["/tmp/file"],
},
},
},
})
}),
)
await transport.close()
})
@@ -1201,8 +1222,8 @@ describe("V2 mini transport", () => {
})
let projected = false
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: projected
messageList(
projected
? [
{
id: "msg_prompt",
@@ -1214,8 +1235,7 @@ describe("V2 mini transport", () => {
},
]
: [],
cursor: {},
}),
),
)
const ui = footer()
const transport = await createSessionTransport({
@@ -1281,8 +1301,8 @@ describe("V2 mini transport", () => {
},
})
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: projected
messageList(
projected
? [
{
id: "msg_prompt",
@@ -1294,8 +1314,7 @@ describe("V2 mini transport", () => {
},
]
: [],
cursor: {},
}),
),
)
const ui = footer()
ui.commits.push({ kind: "user", source: "system", text: "hello", phase: "start", messageID: "msg_prompt" })
@@ -1345,19 +1364,16 @@ describe("V2 mini transport", () => {
const firstPrompt = spyOn(first.session, "prompt")
const firstInterrupt = spyOn(first.session, "interrupt")
spyOn(first.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial" }],
time: { created: 1 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial" }],
time: { created: 1 },
},
]),
)
let releaseHydration!: () => void
let replacementHydrating = false
@@ -1370,22 +1386,19 @@ describe("V2 mini transport", () => {
releaseCatalog = resolve
})
spyOn(second.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_1") return ok({ data: [], cursor: {} })
if (request.sessionID !== "ses_1") return messageList([])
replacementHydrating = true
await hydration
return ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial replacement" }],
time: { created: 1 },
},
],
cursor: {},
})
return messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial replacement" }],
time: { created: 1 },
},
])
})
const current: OpenCodeClient[] = []
const ui = footer()
@@ -1516,19 +1529,16 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "the answer" }],
time: { created: 2, completed: 3 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "the answer" }],
time: { created: 2, completed: 3 },
},
]),
)
let reset!: () => void
const resetting = new Promise<void>((resolve) => {
@@ -1571,19 +1581,16 @@ describe("V2 mini transport", () => {
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial" }],
time: { created: 2, completed: 3 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial" }],
time: { created: 2, completed: 3 },
},
]),
)
const ui = footer()
const live: StreamCommit[] = []
@@ -1638,19 +1645,16 @@ describe("V2 mini transport", () => {
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial" }],
time: { created: 2, completed: 3 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", text: "partial" }],
time: { created: 2, completed: 3 },
},
]),
)
const ui = footer()
const live: StreamCommit[] = []
@@ -1698,7 +1702,7 @@ describe("V2 mini transport", () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.message, "list").mockImplementation(() => ok({ data: [], cursor: {} }))
spyOn(client.message, "list").mockImplementation(() => messageList([]))
const ui = footer()
const live: StreamCommit[] = []
const transport = await createSessionTransport({
@@ -1838,20 +1842,17 @@ describe("V2 mini transport", () => {
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [],
error: { type: "provider.transport", message: "provider failed" },
time: { created: 2, completed: 3 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [],
error: { type: "provider.transport", message: "provider failed" },
time: { created: 2, completed: 3 },
},
]),
)
const ui = footer()
const transport = await createSessionTransport({
@@ -1906,20 +1907,17 @@ describe("V2 mini transport", () => {
await Bun.sleep(0)
expect(live[0]?.messageID).toBe("msg_assistant")
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [],
error: { type: "provider.transport", message: "provider failed" },
time: { created: 2, completed: 3 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [],
error: { type: "provider.transport", message: "provider failed" },
time: { created: 2, completed: 3 },
},
]),
)
await transport.replayOnResize({
@@ -1944,19 +1942,16 @@ describe("V2 mini transport", () => {
footer: ui.api,
})
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_prompt",
type: "user",
text: "hello",
files: [],
agents: [],
time: { created: 2 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_prompt",
type: "user",
text: "hello",
files: [],
agents: [],
time: { created: 2 },
},
]),
)
await transport.replayOnResize({
@@ -1983,33 +1978,30 @@ describe("V2 mini transport", () => {
events.push(connected())
const client = sdk({ streams: [events] })
spyOn(client.message, "list").mockImplementation(() =>
ok({
data: [
{
id: "msg_b",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", text: "second thought" },
{ type: "text", text: "second answer" },
],
time: { created: 4, completed: 5 },
},
{
id: "msg_a",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", text: "first thought" },
{ type: "text", text: "first answer" },
],
time: { created: 2, completed: 3 },
},
],
cursor: {},
}),
messageList([
{
id: "msg_b",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", text: "second thought" },
{ type: "text", text: "second answer" },
],
time: { created: 4, completed: 5 },
},
{
id: "msg_a",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", text: "first thought" },
{ type: "text", text: "first answer" },
],
time: { created: 2, completed: 3 },
},
]),
)
const ui = footer()
@@ -2283,7 +2275,7 @@ describe("V2 mini transport", () => {
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
location: { directory: "/project", workspaceID: "wrk_1" },
location: location("/project", "wrk_1"),
sessionID: "ses_1",
thinking: false,
footer: ui.api,
@@ -2814,14 +2806,16 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1" },
})
})
return ok({
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
type: "user" as const,
payload: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
})
return ok(
wire<Awaited<ReturnType<OpenCodeClient["session"]["command"]>>>({
id: input.id ?? "msg_cmd",
sessionID: "ses_1",
type: "user" as const,
payload: { text: "evaluated template" },
delivery: "steer" as const,
timeCreated: 2,
}),
)
})
await transport.runPromptTurn({
@@ -2968,14 +2962,16 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1" },
})
})
return ok({
id: input.id ?? "msg_skill_attachment",
sessionID: "ses_1",
type: "user" as const,
payload: { text: input.text },
delivery: "steer" as const,
timeCreated: 2,
})
return ok(
wire<Awaited<ReturnType<OpenCodeClient["session"]["prompt"]>>>({
id: input.id ?? "msg_skill_attachment",
sessionID: "ses_1",
type: "user" as const,
payload: { text: input.text },
delivery: "steer" as const,
timeCreated: 2,
}),
)
})
await transport.runPromptTurn({
@@ -3015,10 +3011,7 @@ describe("V2 mini transport", () => {
let refreshes = 0
const transport = await createSessionTransport({
sdk: client,
location: {
directory: "/project",
workspaceID: "work-1",
},
location: location("/project", "work-1"),
sessionID: "ses_1",
thinking: false,
footer: ui.api,
@@ -3528,7 +3521,7 @@ describe("V2 mini transport", () => {
childHydrating = true
await hydration
}
return ok({ data: [], cursor: {} })
return messageList([])
})
const ui = footer()
const transport = await createSessionTransport({
@@ -3592,34 +3585,31 @@ describe("V2 mini transport", () => {
releaseRetry = resolve
})
spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
if (request.sessionID !== "ses_child") return messageList([])
childRequests++
if (childRequests === 1) {
await stale
return ok({ data: [], cursor: {} })
return messageList([])
}
await retry
return ok({
data: [
{
id: "msg_overflow_assistant",
type: "assistant" as const,
agent: "explore",
model: { providerID: "test", id: "model" },
content: [{ type: "text" as const, id: "txt_overflow_64", text: "live 64" }],
time: { created: 2, completed: 3 },
},
{
id: "msg_overflow_baseline",
type: "user" as const,
text: "baseline history",
files: [],
agents: [],
time: { created: 1 },
},
],
cursor: {},
})
return messageList([
{
id: "msg_overflow_assistant",
type: "assistant" as const,
agent: "explore",
model: { providerID: "test", id: "model" },
content: [{ type: "text" as const, text: "live 64" }],
time: { created: 2, completed: 3 },
},
{
id: "msg_overflow_baseline",
type: "user" as const,
text: "baseline history",
files: [],
agents: [],
time: { created: 1 },
},
])
})
const ui = footer()
const transport = await createSessionTransport({
@@ -3687,35 +3677,32 @@ describe("V2 mini transport", () => {
releaseHydration = resolve
})
spyOn(client.message, "list").mockImplementation(async (request) => {
if (request.sessionID !== "ses_child") return ok({ data: [], cursor: {} })
if (request.sessionID !== "ses_child") return messageList([])
childHydrating = true
await hydration
return ok({
data: [
{
id: "msg_tool_projected",
type: "assistant" as const,
agent: "explore",
model: { providerID: "test", id: "model" },
content: [
{
type: "tool" as const,
id: "call_overlap",
name: "shell",
state: {
status: "completed" as const,
input: { command: "projected" },
content: [{ type: "text" as const, text: "projected result" }],
metadata: {},
},
time: { created: 1, ran: 1, completed: 2 },
return messageList([
{
id: "msg_tool_projected",
type: "assistant" as const,
agent: "explore",
model: { providerID: "test", id: "model" },
content: [
{
type: "tool" as const,
id: "call_overlap",
name: "shell",
state: {
status: "completed" as const,
input: { command: "projected" },
content: [{ type: "text" as const, text: "projected result" }],
metadata: {},
},
],
time: { created: 1, completed: 2 },
},
],
cursor: {},
})
time: { created: 1, ran: 1, completed: 2 },
},
],
time: { created: 1, completed: 2 },
},
])
})
const ui = footer()
const transport = await createSessionTransport({
@@ -1,16 +1,19 @@
import { expect, test } from "bun:test"
import { Workspace } from "@opencode-ai/schema/workspace"
import { newSessionLocation } from "../src/config/new-session-location"
const workspaceID = Workspace.ID.make("wrk_1")
test("uses the launch directory by default", () => {
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID })).toEqual({
directory: "/launch",
})
})
test("inherits the active session location when configured", () => {
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID })).toEqual({
directory: "/session",
workspaceID: "work-1",
workspaceID,
})
})
@@ -23,8 +26,8 @@ test("does not inherit an unavailable active location", () => {
newSessionLocation(
"inherit",
"/launch",
{ directory: "/deleted", workspaceID: "work-1" },
{ directory: "/deleted", workspaceID: "work-1" },
{ directory: "/deleted", workspaceID },
{ directory: "/deleted", workspaceID },
),
).toEqual({ directory: "/launch" })
})
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Credential } from "@opencode-ai/schema/credential"
import { hasConnectedProvider } from "../../src/util/connected-provider"
describe("hasConnectedProvider", () => {
@@ -8,7 +9,11 @@ describe("hasConnectedProvider", () => {
})
test("is true after any provider integration is connected", () => {
expect(hasConnectedProvider([{ connections: [{ type: "credential", id: "cred_1", label: "Work" }] }])).toBe(true)
expect(
hasConnectedProvider([
{ connections: [{ type: "credential", id: Credential.ID.make("cred_1"), label: "Work" }] },
]),
).toBe(true)
expect(hasConnectedProvider([{ connections: [{ type: "env", name: "OPENAI_API_KEY" }] }])).toBe(true)
})
})
+13 -11
View File
@@ -1,16 +1,18 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { lastAssistantWithUsage, sessionFamily } from "../../src/util/session"
import { wire } from "../fixture/wire"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
const assistant = (id: string, input: number) =>
wire<SessionMessageInfo>({
id,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [],
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0 },
})
describe("util.session", () => {
test("flattens nested subagents from any session in the family", () => {
@@ -44,7 +46,7 @@ describe("util.session", () => {
})
test("resets usage at completed compaction until the next assistant reports it", () => {
const compaction: SessionMessageInfo = {
const compaction = wire<SessionMessageInfo>({
id: "msg_compaction",
type: "compaction",
status: "completed",
@@ -52,7 +54,7 @@ describe("util.session", () => {
summary: "Current state",
recent: "",
time: { created: 0 },
}
})
const messages = [assistant("msg_before", 30), compaction]
expect(lastAssistantWithUsage(messages)).toBeUndefined()