Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 74e7137c10 fix(tui): keep closed tabs closed 2026-08-02 15:38:19 -04:00
175 changed files with 980 additions and 2278 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
+1 -32
View File
@@ -3,9 +3,8 @@
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts
import { Effect, Layer } from "effect"
import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/ai"
import { RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers"
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
@@ -21,10 +20,6 @@ const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
console.log(response.text)
})
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
@@ -205,32 +200,6 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Testing
Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect
the requests sent by code under test:
```ts
import { Effect } from "effect"
import { TestLLM } from "@opencode-ai/ai/testing"
const testLLM = TestLLM.layer({
fallback: TestLLM.text("Hello from the test model", "text-1"),
})
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
const programWithTestClient = Effect.gen(function* () {
const result = yield* program
const test = yield* TestLLM.Service
console.log(test.requests)
return result
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
```
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
available on the yielded `TestLLM.Service`.
## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
+2 -2
View File
@@ -15,11 +15,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, AIError, Service> =>
): Effect.Effect<ImageResponse, AIError> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
})
}) as Effect.Effect<ImageResponse, AIError>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
+3 -3
View File
@@ -1,5 +1,5 @@
import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient, Service } from "./route/client"
import { LLMClient } from "./route/client"
import {
GenerationOptions,
HttpOptions,
@@ -151,10 +151,10 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
*/
export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError>
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
): Effect.Effect<GenerateObjectResponse<unknown>, AIError>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) {
const { schema, ...rest } = options
+4 -4
View File
@@ -422,18 +422,18 @@ const generateWith = (stream: Interface["stream"]) =>
)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError> {
return Stream.unwrap(
Effect.gen(function* () {
return (yield* Service).stream(request, options)
}),
)
) as Stream.Stream<LLMEvent, AIError>
}
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError> {
return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options)
})
}) as Effect.Effect<LLMResponse, AIError>
}
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
-10
View File
@@ -1,7 +1,5 @@
import { Effect } from "effect"
import {
Image,
ImageClient,
ImageInput,
ImageModel,
type ImageModelOptions,
@@ -9,13 +7,8 @@ import {
type ImageRequestFor,
type ImageRoute,
} from "../src"
import type { Service } from "../src/image-client"
import { Google, OpenAI, XAI, ZAI } from "../src/providers"
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
type GoogleLikeOptions = {
readonly aspectRatio?: "1:1" | "16:9"
readonly imageSize?: "1K" | "2K"
@@ -153,9 +146,6 @@ const request = Image.request({
})
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
void typedRequest
const generated = ImageClient.generate(request)
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, Service>>
void (true satisfies GenerateRequirements)
// @ts-expect-error Image requests no longer expose a common count option.
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
+4 -32
View File
@@ -1,11 +1,5 @@
import { Effect, Schema, Stream } from "effect"
import {
LLM,
type LLMClientService,
type LanguageModel,
type LanguageModelProviderOptions,
type ProviderOptions,
} from "../src"
import { Schema } from "effect"
import { LLM, type LanguageModel, type LanguageModelProviderOptions, type ProviderOptions } from "../src"
import { OpenAIChat } from "../src/protocols"
interface ExampleOptions {
@@ -21,19 +15,9 @@ const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } })
.model<ExampleProviderOptions>({ id: "example" })
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
LLM.request({
model,
prompt: "Hello",
@@ -41,20 +25,12 @@ LLM.request({
providerOptions: { example: { mode: "slow" } },
})
const generatedObject = LLM.generateObject({
LLM.generateObject({
model,
prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } },
})
type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>>
const generatedDynamicObject = LLM.generateObject({
model,
prompt: "Hello",
jsonSchema: { type: "object" },
})
type GenerateDynamicObjectRequirements = Assert<Equal<Requirements<typeof generatedDynamicObject>, LLMClientService>>
LLM.generateObject({
model,
@@ -68,8 +44,4 @@ declare const generic: LanguageModel
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void (options satisfies LanguageModelProviderOptions<typeof model>)
void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements)
void (true satisfies GenerateObjectRequirements)
void (true satisfies GenerateDynamicObjectRequirements)
void options
@@ -45,10 +45,6 @@ describe("timeline fixture validation", () => {
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
})
test("uses the projected tool ID as its call ID", () => {
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
})
})
if (false) {
@@ -4,13 +4,15 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type {
AssistantMessage,
GlobalEvent,
Message,
Part,
Session,
SessionStatus,
ToolPart,
ToolState,
UserMessage,
} from "../../../src/types"
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
} from "@opencode-ai/sdk/v2/client"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -25,29 +27,18 @@ export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type Session = SessionV1Info
type GlobalEvent = {
directory: string
project?: string
workspace?: string
payload: {
id: string
type: string
properties: Record<string, unknown>
type TimelinePayload = Extract<
GlobalEvent["payload"],
{
type:
| "message.updated"
| "message.removed"
| "message.part.updated"
| "message.part.removed"
| "message.part.delta"
| "session.status"
}
}
type TimelineProperties = {
"message.updated": { sessionID: string; info: Message }
"message.removed": { sessionID: string; messageID: string }
"message.part.updated": { sessionID: string; part: Part; time: number }
"message.part.removed": { sessionID: string; messageID: string; partID: string }
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
"session.status": { sessionID: string; status: SessionStatus }
}
type TimelinePayload = {
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
}[keyof TimelineProperties]
>
type DeepReadonly<Value> = Value extends readonly unknown[]
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
@@ -106,6 +97,7 @@ export async function setupTimeline(
locale?: string
deviceScaleFactor?: number
seedHistory?: boolean
protocol?: "v1" | "v2"
} = {},
) {
const sessions = input.sessions ?? [session()]
@@ -123,7 +115,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20,
})
await mockOpenCodeServer(page, {
protocol: "v2",
protocol: input.protocol,
directory,
project: project(),
provider: provider(),
@@ -243,7 +235,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
}
export function validateTimelineEvent(input: unknown): TimelineEvent {
return decodeEvent(input, decodeOptions) as TimelineEvent
return decodeEvent(input, decodeOptions)
}
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
@@ -468,7 +460,7 @@ export function toolPart(
input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {},
): Omit<ToolPart, "sessionID" | "messageID"> {
const base = { id, type: "tool" as const, callID: id, tool }
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running")
return {
@@ -1,121 +1,6 @@
import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page, Route } from "@playwright/test"
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
test("preserves current messages", () => {
const message = {
id: "msg_current",
type: "user",
time: { created: 1 },
text: "current",
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(currentMessage(message)).toBe(message)
})
test("converts rich legacy messages to current message types", () => {
expect(
currentMessage({
info: { id: "msg_user", role: "user", time: { created: 1 } },
parts: [
{ type: "text", text: "Use @src/a.ts with @explore" },
{
type: "file",
mime: "application/json",
filename: "data.json",
url: "data:application/json;base64,e30=",
},
{
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
},
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
],
}),
).toEqual({
id: "msg_user",
type: "user",
time: { created: 1 },
text: "Use @src/a.ts with @explore",
files: [
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 4, end: 13 },
},
],
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
})
expect(
currentMessage({
info: {
id: "msg_assistant",
role: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
modelID: "model",
providerID: "provider",
variant: "high",
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
parts: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
{
id: "prt_tool",
callID: "call_tool",
type: "tool",
tool: "read",
state: {
status: "completed",
input: { filePath: "src/a.ts" },
output: "contents",
metadata: { title: "a.ts" },
time: { start: 3, end: 4 },
},
},
],
}),
).toEqual({
id: "msg_assistant",
type: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
model: { id: "model", providerID: "provider", variant: "high" },
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { type: "MessageAbortedError", message: "Stopped" },
content: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{
type: "tool",
id: "call_tool",
name: "read",
time: { created: 3, ran: 3, completed: 4 },
state: {
status: "completed",
input: { filePath: "src/a.ts" },
content: [{ type: "text", text: "contents" }],
metadata: { title: "a.ts" },
},
},
],
})
})
import { mockOpenCodeServer } from "../../utils/mock-server"
test("applies message latency after a list response gate is released", async () => {
const events: string[] = []
@@ -145,7 +30,7 @@ test("applies message latency after a list response gate is released", async ()
})
const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
fulfill: () => {
events.push("fulfill")
return Promise.resolve()
@@ -85,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event")
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server"
const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverA = "http://127.0.0.1:4096"
const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a"
const directoryB = "/home/server-b"
@@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => {
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB
return url.origin === serverB && url.searchParams.get("directory") === directoryB
}),
)
.toBe(true)
@@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.poll(() =>
permissionRequests.some((request) => {
const url = new URL(request)
return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
return url.origin === serverA && url.searchParams.get("directory") === directoryA
}),
)
.toBe(true)
@@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([
{
origin: serverA,
directory: undefined,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { response: "once" },
},
])
@@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([
{
origin: serverA,
directory: undefined,
directory: directoryA,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { response: "once" },
},
{
origin: serverA,
directory: undefined,
directory: directoryA,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { reply: "once" },
body: { response: "once" },
},
])
})
@@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("location[directory]")
const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/)
const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
permissionResponses.push({
origin: url.origin,
@@ -181,21 +181,13 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true)
}
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event")
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/api/provider")
return json(route, {
location: { directory },
data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
})
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
return json(route, { data: [] })
if (url.pathname === "/api/model/default") return json(route, { data: null })
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
@@ -219,6 +211,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
@@ -228,6 +222,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") {
@@ -293,25 +288,6 @@ function provider(id: string) {
}
}
function model(remote: boolean) {
const id = remote ? "server-b" : "server-a"
const name = remote ? "Server B" : "Server A"
return {
id,
modelID: id,
providerID: id,
name: `${name} Model`,
family: id,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: Date.now() },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 200_000, output: 32_000 },
}
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
@@ -58,18 +58,22 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/event")
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -128,7 +127,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
@@ -19,25 +19,36 @@ test("restores review mode and selected file per session", async ({ page }) => {
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await selectFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await selectFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "alpha.ts")
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts")
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click")
}
async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file)
@@ -54,7 +65,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
protocol: "v1",
directory,
project: {
id: projectID,
@@ -78,27 +89,22 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }),
})
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: { branch: "feature", defaultBranch: "dev" },
}),
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/api/vcs/diff**", (route) =>
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data:
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
}),
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
protocol: "v1",
directory,
project: {
id: projectID,
@@ -62,32 +62,33 @@ test("keeps the review tree and terminal sized when both panels are open", async
events: () => events.splice(0, 1),
eventRetry: 16,
})
await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: { branch: "review-pane-performance", defaultBranch: "dev" },
branch: "review-pane-performance",
default_branch: "dev",
}),
}),
)
await page.route("**/api/vcs/diff**", (route) => {
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs,
}),
body: JSON.stringify(
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
})
})
await page.route("**/pty*", (route) =>
@@ -108,7 +109,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
}),
)
await page.route("**/api/pty/pty_review_terminal*", (route) =>
await page.route("**/pty/pty_review_terminal*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -126,7 +127,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
}),
)
await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -136,7 +137,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
@@ -148,7 +149,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml")
await expectTree(page, 8, "git-0.ts")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
@@ -171,9 +174,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
return (
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
@@ -187,46 +190,59 @@ test("keeps the review tree and terminal sized when both panels are open", async
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle"))
await refreshedDiff
await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts")
await filter.fill("")
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "generated-2738.ts")
await expectTree(page, 2_773, "action.yml")
await expectStackGeometry(page)
})
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible()
@@ -52,7 +52,7 @@ const editPart = {
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: editPartID,
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
const followingTextID = `${id("msg_assistant", 10)}:text:0`
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
{
id: "prt_0104_text",
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
@@ -295,7 +295,7 @@ function contextTool(
sessionID,
messageID,
type: "tool",
callID: partID,
callID: `call_${partID}`,
tool,
state: {
status,
@@ -10,6 +10,7 @@ import {
status,
textPart,
title,
userID,
userMessage,
} from "../performance/timeline-stability/fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
@@ -18,22 +19,18 @@ import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20
const historyPageSize = 200
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: id,
created: 1700000001000 + index * 2_000,
completed: index < initialPageSize,
}),
]
}).flat()
const assistants = messages.filter((message) => message.info.role === "assistant")
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: userID,
created: 1700000001000 + index * 1_000,
completed: index < initialPageSize,
}),
)
const messages = [userMessage(), ...assistants]
const lastAssistant = assistants.at(-1)!
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
const userPartID = `${messages.at(-2)!.info.id}:text:0`
const lastPartID = assistants.at(-1)!.parts[0]!.id
const userPartID = `prt_${userID}_text`
const completed = {
...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
@@ -62,7 +59,6 @@ for (const scenario of scenarios) {
retry: 20,
})
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: project(),
provider: {
@@ -158,23 +154,15 @@ for (const scenario of scenarios) {
await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover()
const deadline = Date.now() + 10_000
while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 3)).toEqual([
expect(sequence.slice(0, 4)).toEqual([
"messages:start:latest",
"messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
])
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
await page.evaluate(() => {
;(
window as Window & {
@@ -186,9 +174,7 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
history.resolve()
await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
@@ -196,7 +182,7 @@ for (const scenario of scenarios) {
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
])
expect(roots).toEqual([])
expect(roots).toEqual([{ sessionID, messageID: userID }])
const message = messageUpdated(scenario.info)
const idle = status("idle")
@@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
})
function lines(count: number) {
@@ -89,6 +89,7 @@ test.describe("session timeline projection", () => {
const aborted = assistantMessage(
[
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
{ id: "prt_compaction", type: "compaction", auto: true },
],
{
id: "msg_1001_assistant_aborted",
@@ -121,13 +122,13 @@ test.describe("session timeline projection", () => {
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
await expect(page.getByText("Before interruption", { exact: true })).toBeVisible()
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
await expect(page.getByText("Visible provider failure")).toBeVisible()
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
})
test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
const user = userMessage(
[
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
@@ -158,14 +159,10 @@ test.describe("session timeline projection", () => {
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(
page.getByText(
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
{ exact: true },
),
).toBeVisible()
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.getByText(/show all/i)).toBeVisible()
})
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import {
assistantID,
assistantMessage,
reasoningPart,
setupTimeline,
@@ -71,7 +70,7 @@ for (const profile of profiles) {
await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
}
@@ -90,5 +89,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
})
@@ -23,11 +23,10 @@ test("groups singleton and separated context operations at correct boundaries",
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -145,6 +145,7 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
}),
],
})
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
@@ -90,7 +90,7 @@ test("reconnects after a stream error", async ({ page }) => {
})
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
@@ -107,10 +107,10 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/health")
const response = await fetch("/global/health")
return response.json()
})
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
expect(health).toEqual({ healthy: true })
expect(await timeline.transport.connections()).toHaveLength(1)
})
@@ -89,19 +89,23 @@ async function mockServer(page: Page) {
if (url.origin !== server) return route.fallback()
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/api/event")
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
return json(route, { data: [], cursor: {} })
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
if (byId) return json(route, byId)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -10,6 +10,7 @@ const title = "Hidden terminal regression"
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page)
await switchTab(page, titleB)
@@ -66,6 +66,7 @@ async function readProbe(page: Page) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: projectID,
@@ -21,7 +21,7 @@ const words = [
"vector",
]
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverKey = "http://127.0.0.1:4096"
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
@@ -134,7 +134,7 @@ function toolPart(
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 100 + partIndex),
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
@@ -235,17 +235,8 @@ function renderable(part: MessagePart) {
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 }
return message.parts
.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
.sort()
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
@@ -299,10 +290,12 @@ export const fixture = {
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap(currentPartIDs),
expandedShellPartID: targetMessages
.flatMap((message) => message.parts)
.find((part) => part.tool === "bash")!.callID,
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id,
},
}
@@ -33,7 +33,6 @@ test.describe("smoke: session timeline", () => {
test("keeps the visible message fixed while prepending history", async ({ page }) => {
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -92,7 +91,6 @@ test.describe("smoke: session timeline", () => {
test("preserves the timeline gap above the composer", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -119,7 +117,6 @@ test.describe("smoke: session timeline", () => {
test("paints cached session tabs at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -128,19 +125,20 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ server, sourceID, targetID }) => {
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server,
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
@@ -245,7 +243,6 @@ test.describe("smoke: session timeline", () => {
test("paints a cold session tab at the latest message", async ({ page }) => {
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
@@ -254,19 +251,20 @@ test.describe("smoke: session timeline", () => {
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
({ server, sourceID, targetID }) => {
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
server,
server: "http://127.0.0.1:4096",
dirBase64,
sessionId,
})),
),
)
},
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
{ dirBase64: base64Encode(fixture.directory), sourceID: fixture.sourceID, targetID: fixture.targetID },
)
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
await expectSessionTitle(page, fixture.expected.sourceTitle)
@@ -324,7 +322,6 @@ test.describe("smoke: session timeline", () => {
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
protocol: "v2",
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
-1
View File
@@ -12,7 +12,6 @@
"./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/types.ts",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
@@ -4,9 +4,12 @@ import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project and selects its model", async ({ page }) => {
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
let connectedGo = false
let pendingGo = false
const connections: Array<{ integrationID: string; body: unknown }> = []
await mockOpenCodeServer(page, {
protocol: "v2",
directory,
project: {
id: "proj_model_selection_flow",
@@ -43,9 +46,17 @@ test("creates a session in a new project and selects its model", async ({ page }
},
},
],
connected: ["opencode", "opencode-go"],
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
default: { providerID: "opencode", modelID: "free-model" },
}),
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
onConnectKey: (input) => {
connections.push(input)
if (input.integrationID === "opencode-go") pendingGo = true
},
onInstanceDispose: () => {
if (pendingGo) connectedGo = true
},
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
@@ -55,17 +66,6 @@ test("creates a session in a new project and selects its model", async ({ page }
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
user: [
{ providerID: "opencode", modelID: "free-model", visibility: "show" },
{ providerID: "opencode-go", modelID: "go-model-1", visibility: "show" },
],
recent: [],
variant: {},
}),
)
})
await page.goto("/")
@@ -79,7 +79,16 @@ test("creates a session in a new project and selects its model", async ({ page }
const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click()
await expect(page.locator('[data-option-key="opencode:free-model"]')).toBeVisible()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
await page.locator('[data-provider-id="opencode-go"]').click()
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
await modelControl.click()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
+75 -359
View File
@@ -1,12 +1,4 @@
import type { Page, Route } from "@playwright/test"
import type {
JsonValue,
PromptAgentAttachment,
PromptFileAttachment,
SessionMessageAssistant,
SessionMessageInfo,
SessionStructuredError,
} from "@opencode-ai/client/promise"
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
@@ -55,6 +47,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
"/vcs": { branch: "main", default_branch: "main" },
"/session": config.sessions,
}
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
@@ -80,11 +73,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
)
}
if (path === "/global/health")
return config.protocol === "v2" ? json(route, {}) : json(route, { healthy: true })
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider") return json(route, providerConfig(config))
if (path === "/provider")
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
@@ -140,17 +134,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
},
],
})
if (path === "/api/provider")
return json(route, {
location: location(config),
data: currentProviders(providerConfig(config)),
})
if (path === "/api/model") return json(route, { location: location(config), data: currentModels(providerConfig(config)) })
if (path === "/api/model/default")
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
if (path === "/api/command") return json(route, { location: location(config), data: [] })
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
if (path === "/api/mcp/resource")
return json(route, { location: location(config), data: { resources: [], templates: [] } })
@@ -158,31 +142,25 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (integration && route.request().method() === "GET")
return json(route, {
location: location(config),
data: {
id: integration,
name: integration,
methods: config.integrationMethods?.[integration] ?? [{ type: "key", label: "API key" }],
connections: [],
},
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/api\/credential\/[^/]+$/.test(path) && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (path === "/api/location") return json(route, location(config))
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
if (projectCopy && route.request().method() === "POST") {
const input = route.request().postDataJSON() as { directory: string; name?: string }
return json(route, { directory: `${input.directory}/${input.name ?? "copy"}` })
}
if (projectCopy && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
if (path === "/api/path")
return json(route, {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
})
if (path === "/api/permission/request")
return json(route, {
location: location(config),
@@ -199,43 +177,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
if (path === "/api/fs/list" && config.fileList)
return json(route, {
location: location(config),
data: await config.fileList(url.searchParams.get("path") ?? ""),
})
const fileRead = path.match(/^\/api\/fs\/read\/(.+)$/)?.[1]
if (fileRead && config.fileContent) {
const value = await config.fileContent(decodeURIComponent(fileRead))
const content = value && typeof value === "object" && "content" in value ? String(value.content) : String(value ?? "")
return route.fulfill({ status: 200, body: content, headers: { "content-type": "application/octet-stream" } })
}
if (path === "/api/fs/find" && config.findFiles) {
const entries = await config.findFiles({
query: url.searchParams.get("query") ?? "",
dirs: url.searchParams.get("type") ?? undefined,
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
})
return json(route, {
location: location(config),
data: Array.isArray(entries)
? entries.map((entry) =>
typeof entry === "string"
? {
name: entry.split(/[\\/]/).at(-1) ?? entry,
path: entry,
absolute: `${config.directory}/${entry}`,
type: "directory",
ignored: false,
}
: entry,
)
: entries,
})
}
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path === "/api/session") {
const directory = url.searchParams.get("directory")
const parentID = url.searchParams.get("parentID")
@@ -262,9 +208,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
if (path === "/api/session/active") {
const statuses = (
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})
) as Record<string, { type?: string }>
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
return json(route, {
data: Object.fromEntries(
Object.entries(statuses).flatMap(([id, status]) =>
@@ -282,9 +226,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") return json(route, true)
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
return json(route, true)
}
if (
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
route.request().method() === "POST"
@@ -294,8 +241,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
@@ -307,17 +252,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
})
}
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
if (currentMessageMatch) {
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
return json(route, { data: currentMessage(message) })
}
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
return json(route, session ?? {})
}
const projectMatch = path.match(/^\/project\/([^/]+)$/)
if (projectMatch) return json(route, config.project)
@@ -361,7 +300,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
const limit = Number(url.searchParams.get("limit") ?? 80)
const pageData = config.pageMessages(messagesMatch[1], limit, before)
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
if (!pageData.cursor) return json(route, pageData.items)
const cursor = `cursor_${++nextCursor}`
@@ -377,75 +317,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
function location(config: MockServerConfig) {
return {
directory: config.directory,
project: { id: (config.project as { id?: string }).id, directory: config.directory, canonical: config.directory },
project: { id: (config.project as { id?: string }).id, directory: config.directory },
}
}
function providerConfig(config: MockServerConfig) {
return typeof config.provider === "function" ? config.provider() : config.provider
}
function currentProviders(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
return value.all.filter(record).flatMap((provider) =>
typeof provider.id === "string" && typeof provider.name === "string"
? [{ id: provider.id, name: provider.name, package: provider.id }]
: [],
)
}
function currentModels(value: unknown) {
if (!record(value) || !Array.isArray(value.all)) return []
return value.all.filter(record).flatMap((provider) => {
if (typeof provider.id !== "string" || !record(provider.models)) return []
return Object.values(provider.models)
.filter(record)
.flatMap((model) => {
if (typeof model.id !== "string" || typeof model.name !== "string") return []
const limit = record(model.limit) ? model.limit : {}
const cost = record(model.cost) ? model.cost : {}
return [
{
id: model.id,
modelID: model.id,
providerID: provider.id,
name: model.name,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: record(model.variants)
? Object.entries(model.variants).map(([id, settings]) => ({
id,
...(jsonRecord(settings) ? { settings: jsonRecord(settings) } : {}),
}))
: [],
time: { released: Date.now() },
cost: [
{
input: typeof cost.input === "number" ? cost.input : 0,
output: typeof cost.output === "number" ? cost.output : 0,
cache: { read: 0, write: 0 },
},
],
status: "active",
enabled: true,
limit: {
context: typeof limit.context === "number" ? limit.context : 200_000,
output: typeof limit.output === "number" ? limit.output : 32_000,
},
},
]
})
})
}
function currentDefaultModel(value: unknown) {
if (!record(value) || !record(value.default)) return null
const selected = value.default
const models = currentModels(value)
return models.find(
(model) => model.providerID === selected.providerID && model.id === selected.modelID,
) ?? null
}
function currentPermission(value: unknown) {
const permission = value as Record<string, unknown>
if (permission.action) return permission
@@ -489,222 +364,63 @@ export function currentSession(session: { id: string } & Record<string, unknown>
}
}
export function currentMessage(value: unknown): SessionMessageInfo {
if (isCurrentMessage(value)) return value
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
const info = value.info
const parts = value.parts.filter(record)
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
throw new Error("Invalid legacy message fixture")
const time = {
created: info.time.created,
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
function currentMessage(value: unknown) {
const item = value as {
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
parts: Array<Record<string, unknown> & { type: string }>
}
if (info.role === "user") {
if (item.info.role === "user") {
return {
id: info.id,
id: item.info.id,
type: "user",
time: { created: time.created },
text: parts
time: item.info.time,
text: item.parts
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
.join("\n"),
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
}
}
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
return {
id: info.id,
id: item.info.id,
type: "assistant",
time,
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
model: {
id: typeof info.modelID === "string" ? info.modelID : "model",
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
},
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
}
}
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
}
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
const source = record(part.source) ? part.source : undefined
const sourceText = source && record(source.text) ? source.text : undefined
const mention = mentionFrom(sourceText)
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
return [
{
data,
mime: part.mime,
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
...(typeof part.filename === "string" ? { name: part.filename } : {}),
...(mention ? { mention } : {}),
},
]
}
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
if (typeof part.name !== "string") return []
const mention = mentionFrom(record(part.source) ? part.source : undefined)
return [{ name: part.name, ...(mention ? { mention } : {}) }]
}
function mentionFrom(value: Record<string, unknown> | undefined) {
if (
!value ||
typeof value.value !== "string" ||
typeof value.start !== "number" ||
typeof value.end !== "number"
)
return
return { text: value.value, start: value.start, end: value.end }
}
function legacyAssistantContent(
part: Record<string, unknown>,
created: number,
): SessionMessageAssistant["content"] {
if (part.type === "text" && typeof part.text === "string")
return [{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) }]
if (part.type === "reasoning" && typeof part.text === "string") {
const time = record(part.time) ? part.time : undefined
return [
{
type: "reasoning",
text: part.text,
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
...(time && typeof time.start === "number"
? {
time: {
created: time.start,
...(typeof time.end === "number" ? { completed: time.end } : {}),
},
}
: {}),
},
]
}
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
return []
const state = part.state
const time = record(state.time) ? state.time : undefined
const toolTime = {
created: time && typeof time.start === "number" ? time.start : created,
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
}
const input = jsonRecord(state.input) ?? {}
const metadata = jsonRecord(state.metadata)
const base = {
type: "tool" as const,
id: typeof part.callID === "string" ? part.callID : part.id,
name: part.tool,
time: toolTime,
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
}
if (state.status === "pending")
return [{ ...base, state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) } }]
if (state.status === "completed")
return [
{
...base,
state: {
status: "completed",
input,
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
...(metadata ? { metadata } : {}),
time: item.info.time,
agent: item.info.agent ?? "build",
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
cost: item.info.cost,
tokens: item.info.tokens,
error: item.info.error,
content: item.parts.flatMap<unknown>((part) => {
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
if (part.type !== "tool") return []
const state = part.state as Record<string, unknown>
return [
{
type: "tool",
id: part.id,
name: part.tool,
time: state.time ?? { created: item.info.time.created },
state:
state.status === "pending"
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
: state.status === "completed"
? {
status: "completed",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [{ type: "text", text: state.output ?? "" }],
}
: state.status === "error"
? {
status: "error",
input: state.input ?? {},
structured: state.metadata ?? {},
content: [],
error: { type: "ToolError", message: state.error ?? "Tool failed" },
}
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
},
},
]
if (state.status === "error")
return [
{
...base,
state: {
status: "error",
input,
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
...(metadata ? { metadata } : {}),
},
},
]
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
}
function structuredError(value: unknown): SessionStructuredError | undefined {
if (typeof value === "string") return { type: "Error", message: value }
if (!record(value)) return
if (typeof value.type === "string" && typeof value.message === "string")
return { type: value.type, message: value.message }
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
return { type: value.name, message: value.data.message }
}
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
if (!record(value) || !record(value.cache)) return
if (
typeof value.input !== "number" ||
typeof value.output !== "number" ||
typeof value.reasoning !== "number" ||
typeof value.cache.read !== "number" ||
typeof value.cache.write !== "number"
)
return
return {
input: value.input,
output: value.output,
reasoning: value.reasoning,
cache: { read: value.cache.read, write: value.cache.write },
}
}
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
if (
value === "stop" ||
value === "length" ||
value === "tool-calls" ||
value === "content-filter" ||
value === "error" ||
value === "unknown"
)
return value
}
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
if (!record(value)) return
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
const next = jsonValue(item)
return next === undefined ? [] : [[key, next]]
]
}),
)
}
function jsonValue(value: unknown): JsonValue | undefined {
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
return jsonRecord(value)
}
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
}
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
@@ -1,5 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { Project } from "@/types"
import type { Project } from "@opencode-ai/sdk/v2/client"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup } from "solid-js"
@@ -146,7 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
server: ServerConnection.key(serverSDK.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverSDK.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -79,7 +79,7 @@ export function DialogHomeCommandPaletteV2(props: {
server: ServerConnection.key(props.server),
opened: serverCtx.projects.list,
stored: () => serverCtx.sync.data.project,
load: (search, signal) => serverCtx.sdk.currentApi.session.list({ parentID: null, search, limit: 50 }, { signal }),
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
untitled: () => language.t("command.session.new"),
category: () => language.t("command.category.session"),
})
@@ -418,7 +418,7 @@ function ProviderConnection(props: {
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.currentApi.integration.get({
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
@@ -547,7 +547,7 @@ function ProviderConnection(props: {
}
dispatch({ type: "auth.pending" })
await serverSDK()
.currentApi.integration.oauth.connect({
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
@@ -816,7 +816,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().currentApi.integration.connect.key({
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
@@ -947,7 +947,7 @@ function ProviderConnection(props: {
setFormStore("error", undefined)
const result = await serverSDK()
.currentApi.integration.oauth.complete({
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
@@ -1044,7 +1044,7 @@ function ProviderConnection(props: {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.currentApi.integration.oauth.status({
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
+2 -2
View File
@@ -8,7 +8,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@/utils/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(sdk().directory)
sdk()
.currentApi.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import type { Path } from "@/types"
import type { Path } from "@opencode-ai/sdk/v2/client"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -70,20 +70,10 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1")
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
return sdk.currentApi.location
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get()
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.then((result) => result.data)
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -107,7 +97,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
const base = pickerRoot(cleaned) || root() || start()
if (!base) return { query: value, items: directories.slice(0, 5) }
const files = await sdk.currentApi.file
const files = await sdk.api.file
.find({
location: { directory: base },
query: pickerFileSearchQuery(base, value, home()),
@@ -137,7 +127,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.currentApi.file
return sdk.api.file
.list({ location: { directory: absolute } })
.then((result) =>
result.data.map((entry) => ({
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
import type { Path } from "@opencode-ai/sdk/v2/client"
interface DialogSelectDirectoryProps {
title?: string
@@ -61,20 +61,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) === "v1")
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
return sdk.currentApi.location
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get()
.then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
.then((result) => result.data)
.catch(() => undefined)
},
{ initialValue: undefined },
@@ -133,7 +133,7 @@ test("scopes file autocomplete to the current browser root", () => {
test("resolves directory autocomplete from the current browser root", async () => {
const directories: string[] = []
const sdk = {
currentApi: {
api: {
file: {
find: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
@@ -155,7 +155,7 @@ test("resolves directory autocomplete from the current browser root", async () =
test("searches from an absolute root without a default base", async () => {
const directories: string[] = []
const sdk = {
currentApi: {
api: {
file: {
list: (input: { location?: { directory?: string } }) => {
directories.push(input.location?.directory ?? "")
@@ -342,7 +342,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const key = trimPickerPath(directory)
const existing = cache.get(key)
if (existing) return existing
const request = args.sdk.currentApi.file
const request = args.sdk.api.file
.list({ location: { directory: key } })
.then((result) => result.data)
.catch(() => [])
@@ -374,7 +374,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
const results = await args.sdk.currentApi.file
const results = await args.sdk.api.file
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
.then((result) => result.data.map((entry) => entry.path))
.catch(() => [])
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import type { FileNode } from "@/types"
import type { FileNode } from "@opencode-ai/sdk/v2"
describe("buildFileTreeV2Model", () => {
test("builds a sorted tree and flattens expanded directories", () => {
@@ -1,4 +1,4 @@
import type { FileNode } from "@/types"
import type { FileNode } from "@opencode-ai/sdk/v2"
export type FileTreeV2Model = {
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@/types"
import type { FileNode } from "@opencode-ai/sdk/v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
+1 -1
View File
@@ -17,7 +17,7 @@ import {
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@/types"
import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { ReferenceInfo } from "@/types"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -1,6 +1,6 @@
// @ts-nocheck
import { createStore } from "solid-js/store"
import type { Todo } from "@/types"
import type { Todo } from "@opencode-ai/sdk/v2"
import { createPromptState } from "@/context/prompt"
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
import { createPromptInputHistory, PromptInput } from "./prompt-input"
+1 -1
View File
@@ -81,7 +81,7 @@ import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/place
import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import type { ReferenceInfo } from "@/types"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
export { createPromptInputHistory }
export type { PromptInputControls, PromptInputHistory, PromptInputProps, PromptInputState, PromptInputSubmission }
@@ -1,5 +1,5 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPartInput, FilePartInput, Part, TextPartInput } from "@/types"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
@@ -199,7 +199,6 @@ beforeAll(async () => {
directory: "/repo/main",
client: rootClient,
api: rootClient.api,
currentApi: rootClient.api,
url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
@@ -333,7 +332,7 @@ describe("prompt submit worktree selection", () => {
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
expect(createdClients).toEqual([])
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sessionCreateInputs).toEqual([
{
@@ -490,6 +489,9 @@ describe("prompt submit worktree selection", () => {
agents: [],
})
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([
{ id: expect.stringMatching(/^prt_/), type: "text", text: "ls" },
])
})
test("submits slash commands through the current session API", async () => {
@@ -1,4 +1,4 @@
import type { Message, Session } from "@/types"
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
@@ -22,7 +22,6 @@ import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { getDirectory } from "@opencode-ai/core/util/path"
type PendingPrompt = {
abort: AbortController
@@ -42,7 +41,7 @@ export type FollowupDraft = {
}
type FollowupSendInput = {
api: DirectorySDK["currentApi"]["session"]
api: DirectorySDK["api"]["session"]
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
@@ -160,6 +159,10 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
legacyParts: requestParts,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
@@ -261,7 +264,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return Promise.resolve()
}
return sdk()
.currentApi.session.interrupt({ sessionID })
.api.session.interrupt({ sessionID })
.catch(() => {})
}
@@ -345,16 +348,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const worktreeSelection = input.newSessionWorktree?.() || "main"
let sessionDirectory = projectDirectory
let client = sdk().client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await sdk()
.currentApi.projectCopy.create({
projectID: sync().data.project,
strategy: "git_worktree",
directory: getDirectory(projectDirectory),
location: { directory: projectDirectory },
})
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
@@ -363,7 +363,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return undefined
})
if (!createdWorktree) return
if (!createdWorktree?.directory) {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: language.t("common.requestFailed"),
})
return
}
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@@ -373,6 +379,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
throwOnError: true,
})
serverSync().child(sessionDirectory)
}
@@ -382,7 +392,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
const created = await sdk()
.currentApi.session.create({
.api.session.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
@@ -473,10 +483,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const eventID = Event.ID.create()
sdk()
.currentApi.session.shell({
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
})
.catch((err) => {
showToast({
@@ -497,7 +509,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.currentApi.session.command({
.api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
@@ -594,7 +606,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
void sendFollowupDraft({
api: sdk().currentApi.session,
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
draft,
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
@@ -1,4 +1,4 @@
import type { Message, Part } from "@/types"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@/types"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext } from "./session-context-metrics"
const assistant = (
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@/types"
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -10,7 +10,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@/types"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk"
@@ -129,13 +129,9 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
return
}
await serverSDK()
.currentApi.integration.get({ integrationID: providerID })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSDK().currentApi.credential.remove({ credentialID: credential.id })),
)
.client.auth.remove({ providerID })
.then(async () => {
await serverSDK().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -125,17 +125,10 @@ export const SettingsProvidersV2: Component<{
await disableProvider(providerID, name)
return
}
const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.currentApi.integration.get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) =>
serverSdk().currentApi.credential.remove({ credentialID: credential.id, location }),
),
)
.client.auth.remove({ providerID })
.then(async () => {
await serverSdk().client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -1,4 +1,4 @@
import type { LspStatus } from "@/types"
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
+57 -20
View File
@@ -17,7 +17,6 @@ import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
import { authTokenFromCredentials } from "@/utils/server"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -183,6 +182,8 @@ export const Terminal = (props: TerminalProps) => {
const auth = connection.http
const username = auth?.username ?? "opencode"
const password = auth?.password ?? ""
const authToken = connection.type === "http" ? connection.authToken : false
const sameOrigin = new URL(url, location.href).origin === location.origin
let container!: HTMLDivElement
const [local, others] = splitProps(props, [
"pty",
@@ -240,8 +241,18 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = async (cols: number, rows: number) => {
if ((await sdk().protocol) === "v1") {
return sdk()
.client.pty.update({
ptyID: id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
return sdk()
.currentApi.pty.update({
.api.pty.update({
ptyID: id,
location: { directory },
size: { cols, rows },
@@ -522,8 +533,17 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = async () => {
if ((await sdk().protocol) === "v1") {
return sdk()
.client.pty.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
debugTerminal("failed to inspect terminal session", err)
return false
})
}
return sdk()
.currentApi.pty.get({ ptyID: id, location: { directory } })
.api.pty.get({ ptyID: id, location: { directory } })
.then((result) => result.data.status === "exited")
.catch((err) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true
@@ -533,23 +553,33 @@ export const Terminal = (props: TerminalProps) => {
}
const connectToken = async () => {
const endpoint = new URL(`/api/pty/${encodeURIComponent(id)}/connect-token`, url)
endpoint.searchParams.set("location[directory]", directory)
const response = await (platform.fetch ?? globalThis.fetch)(endpoint, {
method: "POST",
headers: {
"x-opencode-ticket": "1",
...(password
? { Authorization: `Basic ${authTokenFromCredentials({ username, password })}` }
: undefined),
},
})
if (response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
if (!response.ok) throw new Error(`PTY connect ticket failed with ${response.status}`)
const result = (await response.json()) as { data?: { ticket?: string } }
if (!result.data?.ticket) throw new Error("PTY connect ticket response did not include a ticket")
return result.data.ticket
if ((await sdk().protocol) === "v1") {
const result = await sdk()
.client.pty.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
// return sdk()
// .api.pty.connectToken({
// ptyID: id,
// location: { directory },
// "x-opencode-ticket": "1",
// })
// .then((result) => result.data.ticket)
}
const retry = (err: unknown) => {
@@ -579,16 +609,23 @@ export const Terminal = (props: TerminalProps) => {
fail(err)
return undefined
})
const protocol = await sdk().protocol
// if (protocol === "v2" && !ticket) return
if (once.value) return
if (disposed) return
const socket = new WebSocket(
terminalWebSocketURL({
protocol,
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken,
}),
)
socket.binaryType = "arraybuffer"
@@ -9,7 +9,7 @@ import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { Session } from "@/types"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import "./titlebar-tab-nav.css"
@@ -19,7 +19,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order"
import type { Session } from "@/types"
import type { Session } from "@opencode-ai/sdk/v2"
function SessionTabSlot(props: {
tab: SessionTab
@@ -105,7 +105,7 @@ function SessionTabEntry(props: {
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.currentApi.session.rename({ sessionID: value.id, title })
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
+1 -1
View File
@@ -192,7 +192,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) =>
sdk.currentApi.session
sdk.api.session
.get({ sessionID: route.sessionId })
.then(normalizeSessionInfo)
.catch(() => {}),
+2 -2
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part, Session } from "@/types"
import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
@@ -124,7 +124,7 @@ export const createDirSyncContext = (
fetch: async (count = 10) => {
const [store, setStore] = current()
setStore("limit", (value) => value + count)
const response = await serverSDK.currentApi.session.list({ directory, limit: store.limit, order: "desc" })
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
const sessions = response.data
.map(normalizeSessionInfo)
.sort((a, b) => cmp(a.id, b.id))
+6 -13
View File
@@ -81,15 +81,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
normalizeDir: path.normalizeDir,
list: (dir) =>
sdk()
.currentApi.file.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
.client.file.list({ path: dir })
.then((x) => x.data ?? []),
onError: (message) => {
showToast({
variant: "error",
@@ -188,10 +181,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
const promise = sdk()
.currentApi.file.read({ path: file, location: { directory } })
.then((data) => {
.client.file.read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
const content = x.data
setLoaded(file, content)
if (!content) return
@@ -212,7 +205,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.currentApi.file.find(
.api.file.find(
{
location: { directory: sdk().directory },
query,
@@ -1,4 +1,4 @@
import type { FileContent } from "@/types"
import type { FileContent } from "@opencode-ai/sdk/v2"
const MAX_FILE_CONTENT_ENTRIES = 40
const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+1 -1
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import type { FileNode } from "@/types"
import type { FileNode } from "@opencode-ai/sdk/v2"
type DirectoryState = {
expanded: boolean
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileContent } from "@/types"
import type { FileContent } from "@opencode-ai/sdk/v2"
export type FileSelection = {
startLine: number
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileNode } from "@/types"
import type { FileNode } from "@opencode-ai/sdk/v2"
type WatcherEvent = {
type: string
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, Project } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
@@ -76,30 +75,9 @@ function directoryState() {
}
describe("bootstrapDirectory", () => {
test("uses current MCP endpoints while retaining unsupported v1 directory reads", async () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
const currentApi = {
...api,
command: {
list: async () => {
mcpReads.push("command")
return { location: {}, data: [] }
},
},
mcp: {
list: async () => {
mcpReads.push("status")
return { location: {}, data: [] }
},
resource: {
catalog: async () => {
mcpReads.push("resource")
return { location: {}, data: { resources: [], templates: [] } }
},
},
},
} as unknown as ServerApi
await bootstrapDirectory({
directory: "/project",
@@ -141,7 +119,7 @@ describe("bootstrapDirectory", () => {
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api: currentApi,
api,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
@@ -162,21 +140,12 @@ describe("bootstrapDirectory", () => {
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const location = {} as Parameters<typeof loadPathQuery>[2]
const client = {} as Parameters<typeof loadPathQuery>[3]
const client = {} as Parameters<typeof loadPathQuery>[2]
const api = {} as CatalogApi
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([
"local",
"/repo",
"path",
])
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([
"https://debian.example",
"/repo",
"path",
])
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
@@ -1,5 +1,6 @@
import type {
Config,
OpencodeClient,
Path,
PermissionRequest,
Project,
@@ -7,8 +8,7 @@ import type {
QuestionRequest,
ReferenceInfo,
Session,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
} from "@opencode-ai/sdk/v2/client"
import type {
AgentListInput,
AgentListOutput,
@@ -16,8 +16,6 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
LocationGetInput,
LocationGetOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
@@ -117,7 +115,6 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
@@ -142,7 +139,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
serverAPI: CatalogApi & { readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
@@ -155,12 +152,9 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() =>
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI),
),
() =>
input.queryClient.fetchQuery(
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol),
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
@@ -225,11 +219,17 @@ export const loadProvidersQuery = (
scope: ServerScope,
directory: string | null,
sdk: CatalogApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions({
queryKey: [scope, directory, "providers"],
queryFn: () =>
retry(async () => {
if ((await protocol) === "v1" && legacy) {
const result = await legacy.provider.list()
return normalizeProviderList(result.data!)
}
const location = directory ? { location: { directory } } : undefined
const [providers, models, defaultModel] = await Promise.all([
sdk.provider.list(location),
@@ -256,38 +256,54 @@ export const loadAgentsQuery = (
scope: ServerScope,
directory: string,
sdk: AgentListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions({
queryKey: [scope, directory, "agents"],
queryFn: () =>
retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
retry(async () => {
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
}),
})
export const loadCommands = (
directory: string,
api: CommandListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
): Promise<CommandInfo[]> =>
retry(() => api.list({ location: { directory } }).then((result) => result.data))
retry(async () => {
if ((await protocol) === "v1" && legacy) {
return ((await legacy.command.list()).data ?? []).map((command) => {
const [providerID, id] = command.model?.split("/") ?? []
return {
name: command.name,
template: command.template,
description: command.description,
agent: command.agent,
model: providerID && id ? { providerID, id } : undefined,
subtask: command.subtask,
// source: command.source === "skill" ? undefined : command.source,
}
})
}
return api.list({ location: { directory } }).then((result) => result.data)
})
export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
api: LocationApi,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: async () => {
if ((await protocol) === "v1")
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}))
if ((await protocol) !== "v1")
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
},
})
@@ -295,11 +311,16 @@ export const loadReferencesQuery = (
scope: ServerScope,
directory: string,
api: ReferenceListApi,
legacy?: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<ReferenceInfo[]>({
queryKey: [scope, directory, "references"] as const,
queryFn: () =>
retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
retry(async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? []
return api.list({ location: { directory } }).then((result) => result.data)
}).catch(() => []),
placeholderData: [],
})
@@ -318,7 +339,6 @@ export async function bootstrapDirectory(input: {
readonly reference: ReferenceListApi
readonly session: SessionApi
readonly vcs: VcsApi
readonly location: LocationApi
}
store: Store<State>
setStore: SetStoreFunction<State>
@@ -353,7 +373,7 @@ export async function bootstrapDirectory(input: {
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
@@ -392,9 +412,7 @@ export async function bootstrapDirectory(input: {
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol),
)
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
@@ -410,12 +428,12 @@ export async function bootstrapDirectory(input: {
}),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command).then((commands) =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
input.setStore("command", commands),
)),
() =>
input.queryClient.fetchQuery(
loadReferencesQuery(input.scope, input.directory, input.api.reference),
loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol),
),
() =>
retry(() =>
@@ -493,16 +511,16 @@ export async function bootstrapDirectory(input: {
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpQuery(input.scope, input.directory, input.api.mcp),
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp),
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api))
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
.catch((err) => {
const project = getFilename(input.directory)
showToast({
@@ -1,7 +1,7 @@
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@/types"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@/types"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
@@ -9,7 +9,7 @@ import type {
Session,
SessionStatus,
Todo,
} from "@/types"
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionV2Info } from "@/types"
import type { SessionV2Info } from "@opencode-ai/sdk/v2/client"
import {
applyHomeSessionEvent,
appendHomeSessionEvent,
@@ -1,4 +1,4 @@
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@/types"
import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
import type { QueryClient } from "@tanstack/solid-query"
import { trimSessions } from "./session-trim"
import { pathKey } from "@/utils/path-key"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -1,4 +1,4 @@
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@/types"
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@/types"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { trimSessions } from "./session-trim"
const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
@@ -1,4 +1,4 @@
import type { PermissionRequest, Session } from "@/types"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { cmp } from "./utils"
import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
@@ -12,7 +12,7 @@ import type {
SessionStatus,
Todo,
VcsInfo,
} from "@/types"
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise"
@@ -5,7 +5,7 @@ import type {
PermissionRequest,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@/types"
import type { Agent, Event, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
+1 -1
View File
@@ -7,7 +7,7 @@ import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { usePlatform } from "./platform"
import type { Project } from "@/types"
import { Project } from "@opencode-ai/sdk/v2"
import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
+1 -1
View File
@@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { decode64 } from "@/utils/base64"
import type { EventSessionError } from "@/types"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, Session } from "@/types"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
+1 -1
View File
@@ -1,7 +1,7 @@
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@/types"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import type { ServerSDK } from "@/context/server-sdk"
import type { ServerSync } from "./server-sync"
+1 -1
View File
@@ -1,5 +1,5 @@
import { checksum } from "@opencode-ai/core/util/encode"
import type { FilePartSource } from "@/types"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
import { batch, createMemo, type Accessor } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
+2 -2
View File
@@ -1,8 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, createMemo } from "solid-js"
import { type DirectorySDK, useServerSDK } from "./server-sdk"
import { type ServerSDK, useServerSDK } from "./server-sdk"
export type { DirectorySDK }
export type DirectorySDK = ReturnType<ServerSDK["ensureDirSdkContext"]>
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@/types"
import type { Event } from "@opencode-ai/sdk/v2/client"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
+24 -27
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event, PermissionRequest } from "@/types"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -13,13 +13,12 @@ import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
export type ServerEvent = Event & { current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
@@ -42,9 +41,9 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined,
} satisfies PermissionRequest,
},
current: event,
}
} as ServerEvent
}
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
@@ -193,6 +192,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch)
const [protocolKind] = createResource(
() => protocol,
@@ -260,12 +264,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = eventApi.event.subscribe({ signal: attempt.signal })
const kind = await protocol
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
: eventApi.event.subscribe({ signal: attempt.signal })
let yielded = Date.now()
for await (const event of events) {
streamErrorLogged = false
const directory = event.location?.directory ?? "global"
const payload = adaptServerEvent(event)
const legacy = "payload" in event
if (legacy && event.payload.type === "sync") continue
const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
if (enqueueServerEvent(queue, { directory, payload })) schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
@@ -354,24 +364,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
export type DirectorySDK = {
scope: ServerScope
protocol: Promise<ServerProtocol>
directory: string
client: OpencodeClient
currentApi: ServerApi
api: CompatibleApi
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
readonly url: string
createClient: ServerSDKBase["createClient"]
}
export type ServerSDK = ServerSDKBase & {
ensureDirSdkContext: (directory: string) => DirectorySDK
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope): ServerSDK {
@@ -403,7 +397,11 @@ export function useServerProtocol() {
return createMemo(() => serverSDK().protocolKind())
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
@@ -421,7 +419,6 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
protocol: serverSDK.protocol,
directory,
client,
currentApi: serverSDK.currentApi,
api: createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
@@ -1,8 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { Message, Part, Session } from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
@@ -265,34 +264,6 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.history.more("root")).toBe(false)
})
test("replaces stale current projections on complete refreshes", async () => {
const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
const pages = [
{ data: [first], cursor: { previous: null, next: null } },
{ data: [second], cursor: { previous: null, next: null } },
{ data: [], cursor: { previous: null, next: null } },
]
const messageApi = {
list: async () => pages.shift()!,
} as unknown as MessageApi
const sessionApi = { get: async () => session("root") } as unknown as SessionApi
const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
await store.sync("root", { force: true })
expect(store.data.session_message.root).toEqual([])
expect(store.data.message.root).toEqual([])
})
test("extends a current page to include the user for split assistant turns", async () => {
+4 -11
View File
@@ -3,14 +3,14 @@ import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
Message,
OpencodeClient,
Part,
PermissionRequest,
QuestionRequest,
Session,
SessionStatus,
Todo,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -563,7 +563,7 @@ export function createServerSession(
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
cursor: response.cursor.next ?? undefined,
complete: !response.cursor.next,
complete: response.data.length === 0,
}
}
const response = await (options?.retry ?? retry)(() => {
@@ -683,14 +683,7 @@ export function createServerSession(
? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? []
const boundary = Math.min(...page.source.map((message) => message.time.created))
const current = existing.filter(
(message) =>
!incoming.has(message.id) &&
(page.sourceMode === "older" ||
load?.touchedSource.has(message.id) ||
(!page.complete && message.time.created < boundary)),
)
const current = existing.filter((message) => !incoming.has(message.id))
const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
+63 -38
View File
@@ -1,11 +1,11 @@
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
SessionStatus,
} from "@/types"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
@@ -27,7 +27,7 @@ import {
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
@@ -59,7 +59,6 @@ import type {
} from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
type GlobalStore = {
ready: boolean
@@ -95,6 +94,8 @@ export const loadMcpQuery = (
scope: ServerScope,
directory: string,
api: McpListApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
queryOptions<
Record<string, McpServer["status"]>,
@@ -104,6 +105,7 @@ export const loadMcpQuery = (
>({
queryKey: [scope, directory, "mcp"] as const,
queryFn: async () => {
if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {}
return api
.list({ location: { directory } })
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
@@ -114,6 +116,8 @@ export const loadMcpResourcesQuery = (
scope: ServerScope,
directory: string,
api: McpResourceApi,
legacy?: OpencodeClient,
protocol?: Promise<"v1" | "v2">,
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
queryOptions<
Record<string, McpResource>,
@@ -123,6 +127,14 @@ export const loadMcpResourcesQuery = (
>({
queryKey: [scope, directory, "mcpResources"] as const,
queryFn: async () => {
if ((await protocol) === "v1" && legacy) {
return Object.fromEntries(
Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [
key,
{ ...resource, server: resource.client },
]),
)
}
return api.resource
.catalog({ location: { directory } })
.then((result) =>
@@ -174,13 +186,16 @@ function makeQueryOptionsApi(
return {
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
references: (directory: PathKey) =>
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
mcpResources: (directory: PathKey) =>
loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
@@ -189,7 +204,6 @@ export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContextInner(serverSDK: ServerSDK) {
const language = useLanguage()
const platform = usePlatform()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
@@ -210,11 +224,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return sdk
}
const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol,
})
const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope,
() => serverSDK.client,
serverSDK.currentApi,
serverSDK.api,
sdkFor,
serverSDK.protocol,
)
@@ -225,7 +241,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const activeSessionsQuery = useQuery(() =>
loadActiveSessionsQuery(serverSDK.scope, {
active: async () => {
const active = await serverSDK.currentApi.session.active()
if ((await serverSDK.protocol) === "v1") {
const statuses = (await serverSDK.client.session.status()).data ?? {}
seedActiveSessionStatuses(session, statuses)
for (const sessionID of Object.keys(statuses)) {
void session.resolve(sessionID).catch(() => undefined)
}
return Object.fromEntries(
Object.entries(statuses).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
}
const active = await serverSDK.api.session.active()
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined)
@@ -294,7 +322,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
serverAPI: serverSDK.currentApi,
serverAPI: serverSDK.api,
protocol: serverSDK.protocol,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
@@ -335,7 +363,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
void bootstrapInstance(directory)
},
onMcp: (directory, setStore) => {
void loadCommands(directory, serverSDK.currentApi.command)
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
.then((commands) => setStore("command", commands))
.catch((err) => {
showToast({
@@ -388,7 +416,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
.fetchQuery({
...queryOptionsApi.sessions(key),
queryFn: () =>
loadRootSessions({ api: serverSDK.currentApi.session, directory, limit })
serverSDK.protocol
.then((protocol) =>
protocol === "v1"
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
)
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -458,7 +491,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
provider: globalStore.provider,
},
sdk,
api: serverSDK.currentApi,
api: serverSDK.api,
store: child[0],
setStore: child[1],
vcsCache: cache,
@@ -659,35 +692,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
const sdk = sdkFor(key)
const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status
if (!status) return
await toggleMcp({
status,
connect: async () => {
await serverSDK.currentApi.mcp.connect({ server: name, location: { directory: key } })
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.connect({ name })
return
}
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
},
disconnect: async () => {
await serverSDK.currentApi.mcp.disconnect({ server: name, location: { directory: key } })
if ((await serverSDK.protocol) === "v1") {
await sdk.mcp.disconnect({ name })
return
}
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
},
authenticate: async () => {
const server = (await serverSDK.currentApi.mcp.list({ location: { directory: key } })).data.find(
(item) => item.name === name,
)
if (!server?.integrationID) throw new Error(`MCP server ${name} has no authentication integration`)
const integration = await serverSDK.currentApi.integration.get({
integrationID: server.integrationID,
location: { directory: key },
})
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.prompts?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.currentApi.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
inputs: {},
location: { directory: key },
})
platform.openLink(attempt.data.url)
await sdk.mcp.auth.authenticate({ name })
},
refresh: async () => {
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@/types"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Session } from "@/types"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
+37 -17
View File
@@ -248,12 +248,20 @@ function createWorkspaceTerminalSession(
setStore("all", index, (item) => ({ ...item, ...pty }))
}
const doUpdate = async () => {
await sdk.currentApi.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
if ((await sdk.protocol) === "v1") {
await sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
} else {
await sdk.api.pty.update({
ptyID: pty.id,
location,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
}
}
doUpdate().catch((error: unknown) => {
if (previous) {
@@ -268,13 +276,20 @@ function createWorkspaceTerminalSession(
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const data = await sdk.currentApi.pty
.create({ location, title: pty.title })
.then((result) => result.data)
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
const data = await (async () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: pty.title })).data
}
return (
await sdk.api.pty.create({
location,
title: pty.title,
})
).data
})().catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!data?.id) return
const active = store.active === pty.id
@@ -311,9 +326,10 @@ function createWorkspaceTerminalSession(
const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined
const doCreate = async () => {
return sdk.currentApi.pty
.create({ location, title: defaultTitle(nextNumber) })
.then((result) => result.data)
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data
}
return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data
}
doCreate()
.then((data) => {
@@ -417,7 +433,11 @@ function createWorkspaceTerminalSession(
})
}
await sdk.currentApi.pty.remove({ ptyID: id, location }).catch((error: unknown) => {
const removePromise =
(await sdk.protocol) === "v1"
? sdk.client.pty.remove({ ptyID: id })
: sdk.api.pty.remove({ ptyID: id, location })
await removePromise.catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
@@ -1,4 +1,4 @@
import type { Session } from "@/types"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMarked } from "@opencode-ai/ui/context/marked"
@@ -1,4 +1,4 @@
import type { Session } from "@/types"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { type Accessor, createMemo, For, Show } from "solid-js"
import { Spinner } from "@opencode-ai/ui/spinner"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
+28 -42
View File
@@ -25,8 +25,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import type { Session } from "@/types"
import { getFilename } from "@opencode-ai/core/util/path"
import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -1189,13 +1189,10 @@ export default function LegacyLayout(props: ParentProps) {
const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target)
const listed = await Promise.resolve(
project?.id ?? serverSDK().currentApi.project.current({ location: { directory: root } }),
project?.id ?? serverSDK().api.project.current({ location: { directory: root } }),
)
.then((value) => (typeof value === "string" ? value : value.id))
.then(async (projectID) => {
await serverSDK().currentApi.projectCopy.refresh({ projectID, location: { directory: root } })
return serverSDK().currentApi.project.directories({ projectID, location: { directory: root } })
})
.then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } }))
.then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root)))
.catch(() => [] as string[])
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
@@ -1240,7 +1237,7 @@ export default function LegacyLayout(props: ParentProps) {
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await listAllSessions(serverSDK().currentApi.session, {
session: await listAllSessions(serverSDK().api.session, {
directory: item,
parentID: null,
order: "desc",
@@ -1406,19 +1403,16 @@ export default function LegacyLayout(props: ParentProps) {
setBusy(directory, true)
const projectID = serverSync().data.project.find((project) => project.worktree === root)?.id
const result = projectID
? await serverSDK()
.currentApi.projectCopy.remove({ projectID, directory, force: false, location: { directory: root } })
.then(() => true)
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
: false
const result = await serverSDK()
.client.worktree.remove({ directory: root, worktreeRemoveInput: { directory } })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.delete.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return false
})
setBusy(directory, false)
@@ -1467,9 +1461,7 @@ export default function LegacyLayout(props: ParentProps) {
})
const dismiss = () => toaster.dismiss(progress)
const sessions = await listAllSessions(serverSDK().currentApi.session, { directory, order: "desc" }).catch(
() => [],
)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
clearWorkspaceTerminals(
directory,
@@ -1603,7 +1595,7 @@ export default function LegacyLayout(props: ParentProps) {
})
const refresh = async () => {
const sessions = await listAllSessions(serverSDK().currentApi.session, {
const sessions = await listAllSessions(serverSDK().api.session, {
directory: props.directory,
order: "desc",
}).catch(() => [])
@@ -1843,26 +1835,20 @@ export default function LegacyLayout(props: ParentProps) {
const createWorkspace = async (project: LocalProject) => {
clearSidebarHoverState()
const created = project.id
? await serverSDK()
.currentApi.projectCopy.create({
projectID: project.id,
strategy: "git_worktree",
directory: getDirectory(project.worktree),
location: { directory: project.worktree },
})
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return undefined
})
: undefined
const created = await serverSDK()
.client.worktree.create({ directory: project.worktree })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("workspace.create.failed.title"),
description: errorMessage(err, language.t("common.requestFailed")),
})
return undefined
})
if (!created?.directory) return
setWorkspaceName(created.directory, getFilename(created.directory), project.id)
setWorkspaceName(created.directory, created.branch ?? getFilename(created.directory), project.id, created.branch)
const local = project.worktree
const key = pathKey(created.directory)

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