Compare commits

...

36 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
Kit Langton 97d3cd0b3a refactor: simplify server client helpers (#43976) 2026-08-21 19:37:19 +00:00
Kit Langton 7fd1eee35a refactor(app): remove redundant UI state (#43975) 2026-08-21 19:30:00 +00:00
Kit Langton 2eecf076c4 refactor(config): share entry observer (#43973) 2026-08-21 19:29:08 +00:00
Kit Langton ed08f0e691 refactor(tui): simplify selection helpers (#43972) 2026-08-21 19:28:17 +00:00
Kit Langton 238e1903df refactor(session): tighten persisted state (#43969) 2026-08-21 19:27:21 +00:00
Kit Langton 77c7a7def7 refactor(stats): share unique-users fallback (#43971) 2026-08-21 19:26:43 +00:00
Kit Langton 88788941df refactor: simplify cloud package state (#43970) 2026-08-21 19:25:45 +00:00
Kit Langton d633d794c2 refactor(core): simplify domain helpers (#43968) 2026-08-21 19:24:56 +00:00
Kit Langton 08d52be8c2 refactor(core): simplify tool infrastructure (#43967) 2026-08-21 19:24:06 +00:00
Kit Langton 79d5436d2a refactor(session): simplify model preparation (#43966) 2026-08-21 19:21:04 +00:00
Kit Langton 9a4bd2ba16 refactor(core): simplify tool and shell code (#43948) 2026-08-21 18:45:05 +00:00
Kit Langton e68144cb67 refactor(core): fold single-consumer request modules into model-request (#43958) 2026-08-21 18:44:24 +00:00
Kit Langton e5da5bfab2 refactor(session): simplify effect workflows (#43956) 2026-08-21 18:41:06 +00:00
Kit Langton 3a1fb5ae65 refactor(session): simplify projection state (#43957) 2026-08-21 18:40:53 +00:00
Kit Langton b3d6063329 refactor(core): simplify persistence runtime code (#43954) 2026-08-21 18:40:25 +00:00
Kit Langton 6c3c4bc50f refactor(core): simplify mcp utility flows (#43955) 2026-08-21 18:40:03 +00:00
Kit Langton 7b349654e3 refactor(core): simplify provider runtime code (#43952) 2026-08-21 18:37:20 +00:00
Kit Langton 3d2652d7b9 refactor(core): simplify vcs state helpers (#43953) 2026-08-21 18:35:51 +00:00
Kit Langton 1dea4b9391 refactor(config): simplify utility workflows (#43947) 2026-08-21 18:34:19 +00:00
Kit Langton 15864304a5 refactor(core): clarify guards and constants (#43946) 2026-08-21 18:32:27 +00:00
Kit Langton e312d261a8 refactor(session): simplify history projection (#43944) 2026-08-21 18:25:47 +00:00
Kit Langton 2a83911c7e refactor(plugin): simplify websearch effects (#43943) 2026-08-21 18:23:10 +00:00
Kit Langton 0eaa04718c refactor(config): clarify discovery workflows (#43942) 2026-08-21 18:22:46 +00:00
Kit Langton 2e5ec616d2 feat(tui): optimistic session creation on first prompt (#43687) 2026-08-21 14:21:31 -04:00
opencode-agent[bot] b2551b4e5d fix(tui): stop tab marquee after one cycle (#43936)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-08-21 14:10:54 -04:00
Aiden Cline e81450809d fix(ai): gemini legacy tool media (#43920) 2026-08-21 12:51:26 -05:00
Kit Langton 2524e6be8b refactor(core): simplify persistence boundaries (#43929) 2026-08-21 17:48:27 +00:00
Kit Langton b58f29a4ef refactor(core): simplify plugin and config boundaries (#43930) 2026-08-21 17:48:17 +00:00
Kit Langton 8fec7e0e91 refactor(session): simplify execution and state workflows (#43933) 2026-08-21 17:48:02 +00:00
Kit Langton 94f9d32040 fix(core): re-inject nested instructions after compaction (#43723) 2026-08-21 13:13:24 -04:00
272 changed files with 4848 additions and 3770 deletions
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Nested AGENTS.md instructions are re-injected after compaction. Previously the in-memory dedup claim outlived the synthetic message that compaction dropped from model-visible history, so nested instructions were silently lost for the rest of the process lifetime. The claim now only guards in-flight loads; the synthetic message metadata in durable history is the sole lasting ledger, so any history truncation (compaction, revert) self-heals on the next read in that subtree.
+15 -1
View File
@@ -37,6 +37,10 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
}
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
export interface OptionsInput {
readonly [key: string]: unknown
readonly cachedContent?: string
@@ -284,8 +288,16 @@ const lowerToolCall = (part: ToolCallPart) => ({
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
const contents: GeminiContent[] = []
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
let pendingMedia: GeminiInlineDataPart[] | undefined
const flushMedia = () => {
if (!pendingMedia) return
contents.push({ role: "user", parts: [{ text: "Attached media from tool result:" }, ...pendingMedia] })
pendingMedia = undefined
}
for (const message of request.messages) {
if (message.role !== "tool") flushMedia()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
const previous = contents.at(-1)
@@ -367,6 +379,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const value = ProviderShared.normalizeToolFile(item)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
parts.push({
functionResponse: {
id: functionCallId(part.providerMetadata),
@@ -375,7 +388,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
name: part.name,
content: text.join("\n"),
},
parts: media.length > 0 ? media : undefined,
parts: legacyToolMedia || media.length === 0 ? undefined : media,
},
})
}
@@ -387,6 +400,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
else contents.push({ role: "user", parts })
}
flushMedia()
return contents
})
+159 -5
View File
@@ -328,14 +328,18 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAECAw==" } },
{ inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
],
},
])
expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
}),
@@ -368,11 +372,161 @@ describe("Gemini route", () => {
functionResponse: {
name: "read",
response: { name: "read", content: "" },
parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
},
},
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
],
},
])
}),
)
it.effect("nests media inside function responses for gemini 3", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: gemini3,
messages: [
Message.assistant([
ToolCallPart.make({
id: "call_image",
name: "read",
input: { path: "pixel.png" },
providerMetadata: { google: { thoughtSignature: "sig_1" } },
}),
]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
},
{
role: "user",
parts: [
{
functionResponse: {
name: "read",
response: { name: "read", content: "Image read successfully" },
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
},
},
],
},
])
}),
)
it.effect("flushes pending media before system update text", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "shot", input: {} })]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.system("Update."),
],
}),
)
expect(prepared.body.contents).toEqual([
{ role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
{
role: "user",
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
{ text: "<system-update>\nUpdate.\n</system-update>" },
],
},
])
}),
)
it.effect("collects legacy tool media into one turn after merged responses", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
ToolCallPart.make({ id: "call_1", name: "shot", input: {} }),
ToolCallPart.make({ id: "call_2", name: "shot", input: {} }),
]),
Message.tool({
id: "call_1",
name: "shot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
},
}),
Message.tool({
id: "call_2",
name: "shot",
result: {
type: "content",
value: [{ type: "text", text: "no image here" }],
},
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "model",
parts: [
{ functionCall: { name: "shot", args: {} } },
{ functionCall: { name: "shot", args: {} } },
],
},
{
role: "user",
parts: [
{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } },
{ functionResponse: { name: "shot", response: { name: "shot", content: "no image here" } } },
],
},
{
role: "user",
parts: [
{ text: "Attached media from tool result:" },
{ inlineData: { mimeType: "image/png", data: "AAEC" } },
],
},
])
}),
)
@@ -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)
@@ -7,6 +7,7 @@ import type { ComposerModel } from "./model"
import { createComposerEditor } from "./editor/interaction"
import type { ComposerPersistedState, ComposerSuggestion } from "./types"
import { buildPromptRequest } from "./request"
import { promptLength } from "./prompt-parts"
import { SessionPreview } from "@/session/story-model"
import { Skill } from "@opencode-ai/schema/skill"
import { resolveSessionComposerSelection } from "@/session/composer/selection"
@@ -57,7 +58,7 @@ function ComposerStory(props: {
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
cursor: props.prompt?.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0) ?? 0,
cursor: props.prompt ? promptLength(props.prompt) : 0,
model: { providerID: STORY_MODEL.providerID, modelID: STORY_MODEL.id, variant: STORY_MODEL.variant },
context: { items: props.comments ?? [] },
})
+1 -4
View File
@@ -7,6 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -134,7 +135,3 @@ function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
return next
})
}
function promptLength(prompt: ComposerPrompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -18,6 +18,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
export type ComposerSelectControl = {
options: Accessor<ComposerOption[]>
@@ -434,16 +435,6 @@ function canNavigateHistory(direction: "up" | "down", text: string, cursor: numb
return position === text.length
}
function clonePrompt(prompt: ComposerPersistedState["prompt"]): ComposerPersistedState["prompt"] {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
function promptLength(prompt: ComposerPersistedState["prompt"]) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
function editorCursor(editor: HTMLElement) {
const selection = window.getSelection()
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
+24 -23
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/composer/state"
import { clonePromptParts, prependHistoryEntry, promptLength, type PromptHistoryComment } from "./entry"
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
import { upgradeHistoryState } from "./store"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -34,31 +34,32 @@ describe("Composer history", () => {
expect(dedupedComments).toBe(commentsOnly)
})
test("insertion isolates canonical entries from source mutations", () => {
const prompt: Prompt = [
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 0,
end: 9,
selection: { startLine: 1, startChar: 0, endLine: 2, endChar: 0 },
},
]
const comments = [comment("c1")]
const entries = prependHistoryEntry([], prompt, comments)
const stored = entries[0]
if (prompt[0]?.type !== "file" || stored?.prompt[0]?.type !== "file") throw new Error("expected file")
prompt[0].selection!.startLine = 9
comments[0].selection.start = 9
expect(stored.prompt[0].selection?.startLine).toBe(1)
expect(stored.comments[0]?.selection.start).toBe(2)
})
test("upgrades stored prompt arrays once at the persistence boundary", () => {
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
entries: [{ prompt: text("stored"), comments: [] }],
})
})
test("helpers clone prompt and count text content length", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePromptParts(original)
expect(copy).not.toBe(original)
expect(promptLength(copy)).toBe(12)
if (copy[1]?.type !== "file") throw new Error("expected file")
copy[1].selection!.startLine = 9
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
})
+3 -28
View File
@@ -1,5 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
export const MAX_HISTORY = 100
@@ -20,19 +21,6 @@ export type PromptHistoryEntry = {
export type PromptHistoryStoredEntry = PromptHistoryEntry
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
}
})
}
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
return {
start: selection.start,
@@ -49,17 +37,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
}))
}
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
return {
prompt: clonePromptParts(entry.prompt),
comments: clonePromptHistoryComments(entry.comments),
}
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
}
export function prependHistoryEntry(
entries: PromptHistoryStoredEntry[],
prompt: Prompt,
@@ -75,7 +52,7 @@ export function prependHistoryEntry(
if (!text && !hasImages && !hasComments) return entries
const entry = {
prompt: clonePromptParts(prompt),
prompt: clonePrompt(prompt),
comments: clonePromptHistoryComments(comments),
} satisfies PromptHistoryEntry
const last = entries[0]
@@ -96,9 +73,7 @@ function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryC
)
}
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
const entryA = normalizePromptHistoryEntry(promptA)
const entryB = normalizePromptHistoryEntry(promptB)
function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistoryStoredEntry) {
if (entryA.prompt.length !== entryB.prompt.length) return false
for (let i = 0; i < entryA.prompt.length; i++) {
const partA = entryA.prompt[i]
+3 -3
View File
@@ -3,11 +3,11 @@ import type { Prompt } from "@/composer/state"
import { Persist, persisted } from "@/runtime/persistence/storage"
import {
clonePromptHistoryComments,
clonePromptParts,
prependHistoryEntry,
type PromptHistoryComment,
type PromptHistoryStoredEntry,
} from "./entry"
import { clonePrompt } from "../prompt-parts"
export type ComposerHistoryStore = {
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
@@ -23,7 +23,7 @@ export function upgradeHistoryState(value: unknown) {
return {
...value,
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
if (Array.isArray(entry)) return [{ prompt: clonePrompt(entry as Prompt), comments: [] }]
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
return [entry as PromptHistoryStoredEntry]
@@ -64,7 +64,7 @@ export function createComposerHistory() {
add(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
const ready = mode === "shell" ? shellInit : normalInit
if (!(ready instanceof Promise)) return history.add(prompt, mode, comments)
const saved = clonePromptParts(prompt)
const saved = clonePrompt(prompt)
const metadata = clonePromptHistoryComments(comments)
void ready.then(() => history.add(saved, mode, metadata))
},
+2 -6
View File
@@ -18,7 +18,7 @@ import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode-ai/schema/skill"
import type { ComposerAdapter, ComposerControls } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import { normalizePromptHistoryEntry, type PromptHistoryComment } from "./history/entry"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
@@ -284,11 +284,7 @@ export function createComposerModel(adapter: ComposerAdapter): ComposerModel {
store: prompt.store,
state: interaction,
history: {
entries: (mode) =>
history.entries(mode).map((value) => {
const entry = normalizePromptHistoryEntry(value)
return { prompt: entry.prompt, metadata: entry.comments }
}),
entries: (mode) => history.entries(mode).map((entry) => ({ prompt: entry.prompt, metadata: entry.comments })),
add: (value, mode) => history.add(value, mode, mode === "shell" ? [] : historyComments()),
capture: historyComments,
restore: (metadata) => restoreHistoryComments(metadata as PromptHistoryComment[]),
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
describe("composer prompt parts", () => {
test("clones parts shallowly and copies file selections", () => {
const original: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{
type: "file",
path: "src/a.ts",
content: "@src/a.ts",
start: 3,
end: 12,
selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
},
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
const copy = clonePrompt(original)
expect(copy).not.toBe(original)
expect(copy[0]).not.toBe(original[0])
expect(copy[1]).not.toBe(original[1])
expect(copy[2]).not.toBe(original[2])
if (copy[1]?.type !== "file" || original[1]?.type !== "file") throw new Error("expected file parts")
if (copy[2]?.type !== "image" || original[2]?.type !== "image") throw new Error("expected image parts")
expect(copy[2].blob).toBe(original[2].blob)
expect(copy[1].selection).not.toBe(original[1].selection)
copy[1].selection!.startLine = 9
expect(original[1].selection?.startLine).toBe(1)
})
test("counts the content of text and mention parts", () => {
const prompt: Prompt = [
{ type: "text", content: "one", start: 0, end: 3 },
{ type: "agent", content: "@build", start: 3, end: 9, name: "build" },
{ type: "image", id: "1", filename: "img.png", mime: "image/png", blob: { id: "blob", url: "blob:test" } },
]
expect(promptLength(prompt)).toBe(9)
})
})
+11
View File
@@ -0,0 +1,11 @@
import type { Prompt } from "./state"
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
part.type === "file" ? { ...part, selection: part.selection ? { ...part.selection } : undefined } : { ...part },
)
}
export function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
+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 " },
+1 -20
View File
@@ -8,6 +8,7 @@ import type { BlobReference } from "@/runtime/persistence/drafts"
import type { Platform } from "@/runtime/platform/platform"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Skill } from "@opencode-ai/schema/skill"
import { clonePrompt } from "./prompt-parts"
interface PartBase {
content: string
@@ -108,26 +109,6 @@ type InitialPrompt = {
model?: PromptModel
}
function cloneSelection(selection?: FileSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
if (part.type === "skill") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
+3 -6
View File
@@ -1,8 +1,9 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Event } from "@opencode-ai/schema/event"
import type { Accessor } from "solid-js"
import { clonePromptParts, type PromptHistoryComment } from "./history/entry"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
import { clonePrompt, promptLength } from "./prompt-parts"
import type { ComposerAdapter, ComposerSelection, ComposerSession } from "./adapter"
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
@@ -48,7 +49,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submission = createComposerSubmission({
target: input.adapter.state,
prompt: clonePromptParts(input.adapter.state.current()),
prompt: clonePrompt(input.adapter.state.current()),
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
@@ -317,7 +318,3 @@ function failSubmission(
restore()
input.notify.failed(kind, error)
}
function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
@@ -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: [] },
])
@@ -53,22 +53,6 @@ function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export const loadGlobalConfigQuery = (scope: ServerScope) =>
queryOptions({
queryKey: [scope, "config"],
@@ -126,9 +110,6 @@ export async function bootstrapGlobal(input: {
readonly worktree: WorktreeApi
}
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
@@ -141,12 +122,6 @@ export async function bootstrapGlobal(input: {
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
// showErrors({
// errors: errors(),
// title: input.requestFailedTitle,
// translate: input.translate,
// formatMoreCount: input.formatMoreCount,
// })
}
function projectID(directory: string, projects: Project[]) {
@@ -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"])
-3
View File
@@ -97,9 +97,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
setGlobalStore: setBootStore,
queryClient,
})
+1 -1
View File
@@ -10,7 +10,7 @@ import { createComposerModel } from "@/composer/model"
import { useComposerState } from "@/composer/persistence"
import { createComposerControls } from "@/composer/selection"
import { setCursorPosition } from "@/composer/editor/dom"
import { promptLength } from "@/composer/history/entry"
import { promptLength } from "@/composer/prompt-parts"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLocal } from "@/providers/models/selection"
+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
@@ -16,12 +16,9 @@ import {
createPermissionScopeController,
createShellOptions,
createShellSettingsController,
createSoundSettingsController,
soundOptions,
type AppearanceSettingsController,
type PermissionScopeController,
type ShellSettingsController,
type SoundSettingsController,
} from "./controllers"
import "@/settings/settings.css"
import { ServerConnection } from "@/runtime/server/registry"
@@ -50,24 +47,6 @@ const fontSettings = {
input: "setTerminal",
},
} as const
const soundSettings = {
agent: {
action: "settings-sounds-agent",
title: "settings.general.sounds.agent.title",
description: "settings.general.sounds.agent.description",
},
permissions: {
action: "settings-sounds-permissions",
title: "settings.general.sounds.permissions.title",
description: "settings.general.sounds.permissions.description",
},
errors: {
action: "settings-sounds-errors",
title: "settings.general.sounds.errors.title",
description: "settings.general.sounds.errors.description",
},
} as const
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
const language = useLanguage()
return (
@@ -228,43 +207,6 @@ const FontSetting: Component<{
)
}
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
const language = useLanguage()
return (
<div class="settings-section">
<h3 class="settings-section-title">{language.t("settings.general.section.sounds")}</h3>
<SettingsList>
<SoundSetting kind="agent" channel={props.controller.agent} />
<SoundSetting kind="permissions" channel={props.controller.permissions} />
<SoundSetting kind="errors" channel={props.controller.errors} />
</SettingsList>
</div>
)
}
const SoundSetting: Component<{
kind: "agent" | "permissions" | "errors"
channel: SoundSettingsController["agent"]
}> = (props) => {
const language = useLanguage()
const config = () => soundSettings[props.kind]
return (
<SettingsRow title={language.t(config().title)} description={language.t(config().description)}>
<Select
data-action={config().action}
options={soundOptions}
current={props.channel.current()}
value={(option) => option.id}
label={(option) => language.t(option.label)}
onHighlight={props.channel.highlight}
onSelect={props.channel.select}
placement="bottom-end"
gutter={6}
/>
</SettingsRow>
)
}
const LanguageSetting = () => {
const language = useLanguage()
const options = createMemo(() =>
+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) {
+39 -44
View File
@@ -1,6 +1,6 @@
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
import { createMemo, createResource, For, Index, type JSXElement, Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useMcpToggle } from "@/providers/connect/mcp"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -27,14 +27,12 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const language = useLanguage()
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() =>
mcp()
.map((server) => server.name)
.sort((a, b) => a.localeCompare(b)),
const mcpServers = createMemo(() =>
(data.location.mcp.server.list({ directory: sdk().directory }) ?? []).toSorted((a, b) =>
a.name.localeCompare(b.name),
),
)
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const mcpConnected = createMemo(() => mcpServers().filter((server) => server.status.status === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
@@ -58,26 +56,25 @@ export function StatusPopoverBody(props: { shown: boolean }) {
{language.t("status.popover.tab.mcp")}
</Tabs.Trigger>
{/* TODO: Restore LSP status when V2 exposes it. */}
<Show when={true}>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
{language.t("status.popover.tab.plugins")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpNames().length > 0}
when={mcpServers().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
}
>
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
<Index each={mcpServers()}>
{(server) => {
const name = () => server().name
const status = () => server().status.status
const enabled = () => status() === "connected"
return (
<button
@@ -85,9 +82,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
toggleMcp.mutate(name())
}}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
>
<div
classList={{
@@ -100,7 +97,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
/>
<span class="flex flex-col min-w-0 flex-1">
<span class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-base truncate">{name}</span>
<span class="text-14-regular text-text-base truncate">{name()}</span>
</span>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker truncate">
@@ -112,43 +109,41 @@ export function StatusPopoverBody(props: { shown: boolean }) {
<Switch
appearance="standard"
checked={enabled()}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
disabled={toggleMcp.isPending && toggleMcp.variables === name()}
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
toggleMcp.mutate(name())
}}
/>
</div>
</button>
)
}}
</For>
</Index>
</Show>
</div>
</div>
</Tabs.Content>
<Show when={true}>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
<Tabs.Content value="plugins">
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
<div class="flex items-center gap-2 w-full px-2 py-1">
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
<span class="text-14-regular text-text-base truncate">{plugin}</span>
</div>
)}
</For>
</Show>
</div>
</Tabs.Content>
</Show>
</div>
</Tabs.Content>
</Tabs>
</div>
)
+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) {
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { createClient, loadIntegrations } from "./shared"
import { errorMessage } from "../../../ui/prompt"
import { errorMessage } from "../../../util/error"
export default Runtime.handler(Commands.commands.auth.commands.list, (input) =>
list(input).pipe(
@@ -6,6 +6,7 @@ import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
import { errorMessage } from "../../../util/error"
const integrationID = "opencode"
const location = { directory: process.cwd() }
@@ -24,9 +25,9 @@ export default Runtime.handler(
if (Exit.isSuccess(exit)) return
const cancelled = timeline.signal.aborted
yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe(
Effect.ignore,
)
yield* request(() =>
timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(Cause.squash(exit.cause))),
).pipe(Effect.ignore)
process.exitCode = cancelled ? 130 : 1
}),
)
@@ -107,12 +108,3 @@ function request<A>(task: (signal: AbortSignal) => Promise<A>) {
function required<A>(value: A | null | undefined, message: string) {
return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
}
function errorMessage(cause: Cause.Cause<unknown>) {
const error = Cause.squash(cause)
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
@@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.debug.commands.agents,
Effect.fn("cli.debug.agents")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
process.stdout.write(
@@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.debug.commands.config,
Effect.fn("cli.debug.config")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
+1 -1
View File
@@ -6,7 +6,7 @@ import { EOL } from "node:os"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
import { errorMessage } from "../../ui/prompt"
import { errorMessage } from "../../util/error"
export default Runtime.handler(
Commands.commands.export,
@@ -17,9 +17,7 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.auth,
Effect.fn("cli.mcp.auth")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
@@ -9,9 +9,7 @@ import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler(
Commands.commands.mcp.commands.list,
Effect.fn("cli.mcp.list")(function* () {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
@@ -12,9 +12,7 @@ const location = { directory: process.cwd() }
export default Runtime.handler(
Commands.commands.mcp.commands.logout,
Effect.fn("cli.mcp.logout")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const integration = yield* resolveIntegration(client, input.name, location)
@@ -12,9 +12,7 @@ import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugi
export default Runtime.handler(
Commands.commands.plugin.commands.list,
Effect.fn("cli.plugin.list")(function* (input) {
const options = yield* ServiceConfig.options()
const found = yield* Service.discover(options)
const endpoint = found ?? (yield* Service.ensure(options))
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
const config = yield* Config.Service
+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,
+8 -8
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"
@@ -10,6 +11,7 @@ import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
import { runNonInteractivePrompt } from "./noninteractive"
import { UI } from "./ui"
import { Env } from "../env"
import { errorMessage } from "../util/error"
export type RunCommandInput = {
server: ServerConnection.Resolved
@@ -111,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)
@@ -243,13 +250,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
return error.message
return String(error)
}
/** @internal Used by the V1 command boundary before a Session exists. */
export function reportRunError(input: Pick<RunCommandInput, "format">, message: string, sessionID?: string) {
process.exitCode = 1
+1 -8
View File
@@ -1,5 +1,6 @@
import { cancel, isCancel, log, outro } from "@clack/prompts"
import { Effect } from "effect"
import { errorMessage } from "../util/error"
const cancelled = Symbol("cancelled")
@@ -38,11 +39,3 @@ export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
),
)
}
export function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
+7
View File
@@ -0,0 +1,7 @@
export function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
return error.message
}
return String(error)
}
+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
})
}
+3
View File
@@ -33,12 +33,14 @@ describe("debug config command", () => {
},
]
let requested: URL | undefined
let healthProbes = 0
const authorization: Array<string | null> = []
const server = Bun.serve({
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/health") {
healthProbes += 1
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
}
requested = url
@@ -60,6 +62,7 @@ describe("debug config command", () => {
expect(requested?.pathname).toBe("/api/config")
expect(requested?.searchParams.get("location[directory]")).toBe(project)
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
expect(healthProbes).toBe(1)
} finally {
server.stop(true)
await fs.rm(root, { recursive: true, force: true })
+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
}
@@ -35,7 +35,9 @@ describe("CLI frontend import boundaries", () => {
test("keeps run and Mini on separate evaluation graphs", async () => {
const run = await bundleInputs("packages/cli/src/commands/handlers/run.ts")
expect(run).toContain("packages/cli/src/run/run.ts")
expect(run).toContain("packages/cli/src/util/error.ts")
expect(run).toContain("packages/tui/src/mini/tool.ts")
expect(run).not.toContain("packages/cli/src/ui/prompt.ts")
expect(run).not.toContain("packages/tui/src/mini/runtime.ts")
expect(run).not.toContain("packages/tui/src/mini/runtime.lifecycle.ts")
expect(run).not.toContain("packages/tui/src/mini/footer.ts")
+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)}`,
}
}
+4 -8
View File
@@ -28,7 +28,10 @@ export type Info = import("../service.js").Info
// Never spawns; escalation to ensure() is the caller's policy.
/** Discover a healthy, compatible local service without starting one. */
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
return (yield* discoverLocal(options))?.endpoint
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined
return found.endpoint
})
/** Recognize an authenticated compatible service bound to an expected URL, including while it starts or fails. */
@@ -42,13 +45,6 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
})
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns small contenders until a server
// becomes discoverable. A contender is never killed merely for slow startup.
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -22,14 +22,10 @@ export * from "../service.js"
/** Discover a healthy, compatible local service without starting one. */
export async function discover(options: DiscoverOptions = {}) {
return (await discoverLocal(options))?.endpoint
}
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
return found.endpoint
}
/** Ensure a healthy, compatible local service is running. */
@@ -144,10 +140,6 @@ type LocalService = {
readonly legacy: boolean
}
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
return (await probeResult(info, allowLegacy)).service
}
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
const endpoint = {
url: info.url,
+132 -30
View File
@@ -13,6 +13,7 @@ import type {
McpResource,
McpServer,
ModelInfo,
ModelRef,
PermissionSavedInfo,
PermissionRequest,
PermissionReplyInput,
@@ -33,7 +34,10 @@ 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"
import { isPermissionNotFoundError, type SessionPromptInput } from "../promise"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -57,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.
@@ -226,6 +228,33 @@ export function createData(config: CreateDataInput) {
// rollback — not on POST success, which typically precedes the echo.
const outbox = new Set<string>()
// Session IDs of optimistic create admissions still awaiting acknowledgement
// (the session.created echo or the create response itself). A failed create
// only rolls back a session the server never acknowledged. Unlike
// `creating`, this clears on the echo rather than request settlement.
const sessionOutbox = new Set<string>()
// In-flight optimistic creates by session ID. prompt() gates its POST on
// this so a prompt sent to a still-creating session waits for the session
// to exist server-side instead of failing with "not found".
const creating = new Map<string, Promise<unknown>>()
// Per-session send chain: prompts must be admitted in submission order,
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
// for the previous prompt's POST (settled, so one failure does not block
// the next) before sending its own.
const sending = new Map<string, Promise<unknown>>()
// Register `promise` under `key` until it settles. A later registration
// replaces an earlier one; settlement only clears its own entry.
function track(map: Map<string, Promise<unknown>>, key: string, promise: Promise<unknown>) {
map.set(key, promise)
const settle = () => {
if (map.get(key) === promise) map.delete(key)
}
void promise.then(settle, settle)
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic prompt admission; the upsert is what reconciles
@@ -385,6 +414,7 @@ export function createData(config: CreateDataInput) {
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
messageIndex.delete(sessionID)
sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.family:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
sync.invalidate(`session.permission:${sessionID}`)
@@ -434,6 +464,7 @@ export function createData(config: CreateDataInput) {
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
void result.session.sync(event.data.sessionID)
// Band-aid: a newly created session starts empty, so live events can be its source of truth.
@@ -459,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,
@@ -474,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)
@@ -511,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,
@@ -571,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(", ")}`,
@@ -583,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,
@@ -595,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,
@@ -833,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,
@@ -896,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,
@@ -912,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",
@@ -1110,46 +1141,117 @@ export function createData(config: CreateDataInput) {
sync.invalidate(`session.pending:${sessionID}`)
},
},
// Optimistic session creation: admit a local record under a
// client-minted ID so a session view can mount immediately, then create
// the session on the server. The session.created echo re-syncs the
// record by ID, so the durable payload replaces the client's guess.
// Returns the ID synchronously along with the in-flight request:
// callers gate session-dependent sends on the request (prompt() gates
// itself on any in-flight create of its session automatically).
create(input: {
id?: string
title?: string
agent?: string
model?: ModelRef
location?: LocationRef
projectID?: string
}) {
const { projectID, ...payload } = input
const id = payload.id ?? SessionID.create()
const location = payload.location ?? defaultLocation()
const fresh = !store.session.info[id]
if (fresh) {
const now = Date.now()
sessionOutbox.add(id)
result.session.remember({
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 } },
time: { created: now, updated: now },
title: payload.title,
location,
})
// A mounted optimistic session must not fetch its empty collections
// before creation settles. The session.created echo re-syncs info.
sync.complete(`session.family:${id}`)
sync.complete(`session.pending:${id}`)
sync.complete(`session.message:${id}`)
}
// Wrapped so even a synchronous client failure reaches the rollback.
const request = Promise.resolve()
.then(() => api().session.create({ ...payload, id, location }))
.then((info) => {
sessionOutbox.delete(id)
result.session.remember(info)
return info
})
.catch((error) => {
// Roll back only a record this call admitted and neither the echo
// nor the response has acknowledged: anything else is server state.
if (fresh && sessionOutbox.delete(id)) removeSession(id)
throw error
})
if (fresh) track(creating, id, request)
return { id, request }
},
// Optimistic prompt admission: render the prompt immediately under a
// client-minted ID, send it, and let the durable inbox.enqueued echo
// upsert that same ID with the server's payload. Server admission is
// idempotent per ID, so retrying with the identical payload cannot
// double-admit.
prompt(input: SessionPromptInput) {
const id = input.id ?? SessionMessage.ID.create()
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
const { gate, ...request } = input
const id = request.id ?? SessionMessage.ID.create()
// A retry may reuse an ID that is already rendered — and possibly
// already durable. Admit optimistically only for new IDs so a failed
// retry cannot roll back acknowledged state.
const fresh =
!messageIndex.get(input.sessionID)?.has(id) &&
!store.session.pending[input.sessionID]?.some((item) => item.id === id)
!messageIndex.get(request.sessionID)?.has(id) &&
!store.session.pending[request.sessionID]?.some((item) => item.id === id)
if (fresh) {
outbox.add(id)
admitLocal({
id,
sessionID: input.sessionID,
id: SessionMessage.ID.make(id),
sessionID: SessionID.make(request.sessionID),
timeCreated: Date.now(),
type: "user",
delivery: input.delivery ?? "steer",
delivery: request.delivery ?? "steer",
// Files and skills stay off the optimistic row: their durable
// forms are server-loaded (content, mime, resolution), so they
// fill in when the echo upserts the row.
payload: {
text: input.text,
agents: input.agents?.map((agent) => ({ ...agent })),
metadata: input.metadata,
text: request.text,
agents: request.agents?.map((agent) => ({ ...agent })),
metadata: request.metadata,
},
})
}
// Wrapped so even a synchronous client failure reaches the rollback.
return Promise.resolve()
.then(() => api().session.prompt({ ...input, id }))
.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(input.sessionID, id)
throw error
})
// The POST additionally waits for the caller's gate, for any
// in-flight optimistic create of this session, and for the previous
// prompt's POST: the row renders now, the send happens once the
// session exists server-side and earlier prompts are admitted.
const previous = sending.get(request.sessionID)
const send = Promise.resolve()
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
.then(() => api().session.prompt({ ...request, id }))
track(
sending,
request.sessionID,
send.then(
() => undefined,
() => undefined,
),
)
return send.catch((error) => {
// Roll back only rows this call admitted and the echo has not
// acknowledged: anything else is server state.
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
throw error
})
},
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
@@ -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 -3
View File
@@ -127,7 +127,7 @@ function prepareOptions(model: Info, pkg: string) {
options.timeout !== undefined && options.timeout !== null && options.timeout !== false
? AbortSignal.timeout(options.timeout)
: undefined,
].filter((item): item is AbortSignal | AbortController => Boolean(item))
].filter((item): item is AbortSignal | AbortController => item !== undefined && item !== null)
const chunkAbortCtl = signals.find((item): item is AbortController => item instanceof AbortController)
const abortSignals = signals.map((item) => (item instanceof AbortController ? item.signal : item))
if (abortSignals.length === 1) opts.signal = abortSignals[0]
@@ -346,8 +346,7 @@ function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, u
const prefix = separator > 0 ? modelID.slice(0, separator) : undefined
if (prefix)
return { ...(gateway === undefined ? {} : { gateway }), [prefix === "amazon" ? "bedrock" : prefix]: model }
if (typeof gateway === "object" && gateway !== null && !Array.isArray(gateway))
return { gateway: { ...gateway, ...model } }
if (gateway !== undefined) return { gateway: { ...gateway, ...model } }
return { gateway: model }
}
+30 -32
View File
@@ -227,7 +227,7 @@ export function configured(options?: Options) {
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const durable = definition?.durable
const durable = definition.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
@@ -391,14 +391,14 @@ export function configured(options?: Options) {
commit?: PublishOptions["commit"],
) {
return Effect.gen(function* () {
if (!definition?.durable && commit)
if (!definition.durable && commit)
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: "Local commit hooks require a durable event",
}),
)
if (definition?.durable) {
if (definition.durable) {
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string")
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
@@ -610,37 +610,35 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const definition = Durable.get(event.type)
if (!definition?.durable) {
yield* Effect.die(
if (!definition?.durable)
return yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
} else {
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
}
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
})
}
+1 -1
View File
@@ -134,7 +134,7 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
finalize: Effect.fn("Catalog.finalize")(function* () {
yield* bus.publish(Catalog.Event.Updated, {})
}),
})
+3 -3
View File
@@ -112,15 +112,15 @@ export const create = (
files: collected,
...(result.ok ? {} : { error: true }),
}
const content: Array<Content> = [{ type: "text", text: value.output }]
content.push(
const content: Array<Content> = [
{ type: "text", text: value.output },
...value.files.map((file) => ({
type: "file" as const,
uri: `data:${file.mime};base64,${file.data}`,
mime: file.mime,
...(file.name === undefined ? {} : { name: file.name }),
})),
)
]
const metadata: Metadata = {
toolCalls: value.toolCalls,
...(value.error ? { error: true } : {}),
+40 -49
View File
@@ -17,6 +17,7 @@ import {
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
import { Watcher } from "./filesystem/watcher.js"
@@ -29,9 +30,8 @@ import { ConfigNormalize } from "./config/normalize.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
.filter((entry): entry is Document => entry.type === "document")
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
return entries.findLast((entry): entry is Document => entry.type === "document" && entry.info[key] !== undefined)
?.info[key]
}
export interface Interface {
@@ -156,6 +156,37 @@ export const layer = (options?: Options) =>
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
})
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
const entries = yield* wellknown
.entries()
@@ -164,38 +195,7 @@ export const layer = (options?: Options) =>
Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)),
),
)
return yield* Effect.forEach(entries, (entry) =>
Effect.gen(function* () {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
.resolve(entry, variables)
.pipe(
Effect.catch(() =>
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
Effect.as([] as const),
),
),
)
return yield* Effect.forEach(configs, (config) =>
ConfigVariable.substitute({
type: "virtual",
source: entry.origin,
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
}),
).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(entries, loadWellknownEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
@@ -449,20 +449,11 @@ type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
const previous = before as Record<string, unknown>
const next = after as Record<string, unknown>
return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
if (!(key in next)) return [{ path: [...path, key], value: undefined }]
if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
return changes(previous[key], next[key], [...path, key])
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
+1 -1
View File
@@ -793,7 +793,7 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
function own(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key)
return Object.hasOwn(value, key)
}
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
+13 -17
View File
@@ -52,22 +52,19 @@ export const Plugin = define({
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [entry]
if (entry.type !== "directory") return []
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
})
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
if (entry.type !== "directory") return Effect.succeed([])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => (content ? decode(file, content) : undefined)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Document[] }
const reload = load().pipe(
@@ -160,8 +157,7 @@ function isPathAction(action: string): action is PathAction {
}
function expandHome(resource: string, home: string) {
if (resource === "~") return home
if (resource === "$HOME") return home
if (resource === "~" || resource === "$HOME") return home
const relative = resource.startsWith("~/")
? resource.slice(2)
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
+9 -10
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"
@@ -17,16 +18,14 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
if (entry.type === "document") return [{ commands: entry.info.commands }]
if (entry.type !== "directory") return []
const commands = yield* loadDirectory(fs, entry.path)
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
})
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
if (entry.type !== "directory") return Effect.succeed([])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Info["commands"] }[] }
const reload = load().pipe(
@@ -56,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,
+4 -15
View File
@@ -1,35 +1,24 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.compaction",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(compaction.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined
? {}
: { tokens: entry.info.compaction.keep.tokens }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
})
}
})
@@ -0,0 +1,25 @@
export * as ConfigEntryObserver from "./entry-observer.js"
import type { EventDomain } from "@opencode-ai/plugin/effect/event"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
export const observe = Effect.fnUntraced(function* (
config: Config.Interface,
event: EventDomain,
reload: Effect.Effect<void>,
) {
const loaded = { entries: yield* config.entries() }
const refresh = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(reload),
)
yield* event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => refresh),
Effect.forkScoped({ startImmediately: true }),
)
// Close the race between the first read and establishing the subscription.
loaded.entries = yield* config.entries()
return loaded
})
+3 -15
View File
@@ -5,11 +5,12 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Formatter } from "../../formatter.js"
import { make, type Info } from "../../formatter/builtins.js"
import { Location } from "../../location.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.formatter",
@@ -21,20 +22,7 @@ export const Plugin = define({
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
yield* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")

Some files were not shown because too many files have changed in this diff Show More