mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 03:36:10 +00:00
Compare commits
8
Commits
dev
..
ai-msg-phases
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4089f53c | ||
|
|
9c7bf1c219 | ||
|
|
6b13d117d3 | ||
|
|
1114137eb6 | ||
|
|
1f5ef37970 | ||
|
|
a2fbbc31d1 | ||
|
|
bdf01ee676 | ||
|
|
4375ce1dce |
@@ -37,7 +37,6 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -303,7 +302,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -578,7 +577,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -799,7 +798,6 @@
|
||||
"version": "1.18.4",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -1076,12 +1074,12 @@
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1202,7 +1200,7 @@
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.34", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HpK28sWGdIfg1vTSScJNtzVdvNRfA4mfCmPmPR+j/MGJ0oAuEJMqxWkL96ZnGPdhZt5KdW09aKovdIe+q2zQ7A=="],
|
||||
|
||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||
|
||||
@@ -5702,9 +5700,9 @@
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="],
|
||||
|
||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||
|
||||
@@ -6030,8 +6028,6 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
@@ -6050,8 +6046,6 @@
|
||||
|
||||
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
|
||||
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
|
||||
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
|
||||
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
|
||||
"x86_64-linux": "sha256-L741oedvozk0cIVnaZnujvwWrK+WXINv9KiKxYRfVwQ=",
|
||||
"aarch64-linux": "sha256-ThzQ4nCLbaLiKA7cBHI7OMAlXb+8Hchm3HojGnIAEz0=",
|
||||
"aarch64-darwin": "sha256-YdXOgFgYRu4tKw90+7F1reCihO+JC33dGk51J8NTRIk=",
|
||||
"x86_64-darwin": "sha256-Ea5X2mYHGch3JyA6wC0uH3zBEzQcFT/adqJ1+7LtRdQ="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
|
||||
@@ -97,7 +97,6 @@ export async function setupTimeline(
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
protocol?: "v1" | "v2"
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
@@ -115,7 +114,6 @@ export async function setupTimeline(
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: input.protocol,
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
|
||||
@@ -105,9 +105,5 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`,
|
||||
})
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await panel.locator("#session-side-panel-review-tab").click()
|
||||
await panel.getByRole("tab", { name: /Review/ }).click()
|
||||
await expect(sidebarToggle).toBeEnabled()
|
||||
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||
await page.keyboard.press("Control+w")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||
await page.getByRole("option", { name: next }).click()
|
||||
}
|
||||
|
||||
async function selectFile(page: Page, file: string) {
|
||||
|
||||
@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
@@ -100,7 +100,7 @@ test("does not request replay when reconnecting the volatile V2 event stream", a
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||
})
|
||||
|
||||
test("passes through non-event fetches", async ({ page }) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
@@ -55,26 +54,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
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 === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/api/session")
|
||||
return json(route, {
|
||||
data: config.sessions.map((session) => v2Session(session, config.directory)),
|
||||
cursor: {},
|
||||
})
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
@@ -102,129 +89,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (path === "/api/agent")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: [
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
request: { settings: {}, headers: {}, body: {} },
|
||||
permissions: [],
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === "/api/command") 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: [] } })
|
||||
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
||||
if (integration && route.request().method() === "GET")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
})
|
||||
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
|
||||
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.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),
|
||||
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
if (path === "/api/question/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||
})
|
||||
if (path === "/api/vcs")
|
||||
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/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")
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||
const sessions = config.sessions
|
||||
.filter((session) => !directory || session.directory === directory)
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
!search ||
|
||||
String(session.title ?? "")
|
||||
.toLowerCase()
|
||||
.includes(search)
|
||||
)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||
return json(route, {
|
||||
data: data.map((session) => currentSession(session, config.directory)),
|
||||
cursor: { next },
|
||||
})
|
||||
}
|
||||
if (path === "/api/session/active") {
|
||||
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||
return json(route, {
|
||||
data: Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([id, status]) =>
|
||||
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
||||
return json(route, {
|
||||
data: currentSession(session, config.directory),
|
||||
})
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
@@ -247,24 +115,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (currentMessagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: pageData.items.map(currentMessage).reverse(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
@@ -287,37 +137,12 @@ 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 },
|
||||
}
|
||||
}
|
||||
|
||||
function currentPermission(value: unknown) {
|
||||
const permission = value as Record<string, unknown>
|
||||
if (permission.action) return permission
|
||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
||||
return {
|
||||
id: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
action: permission.permission,
|
||||
resources: permission.patterns ?? [],
|
||||
save: permission.always,
|
||||
metadata: permission.metadata,
|
||||
source:
|
||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||
function v2Session(session: { id: string } & Record<string, unknown>, fallbackDirectory: string) {
|
||||
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
agent: session.agent ?? "build",
|
||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: {
|
||||
@@ -332,67 +157,7 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||
},
|
||||
subpath: session.path,
|
||||
revert: session.revert,
|
||||
}
|
||||
}
|
||||
|
||||
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 (item.info.role === "user") {
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "user",
|
||||
time: item.info.time,
|
||||
text: item.parts
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "assistant",
|
||||
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: [] },
|
||||
},
|
||||
]
|
||||
}),
|
||||
...(typeof session.path === "string" ? { subpath: session.path } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,18 +181,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
function currentEvent(input: unknown) {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event" | "/api/event"
|
||||
path: "/global/event" | "/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
@@ -93,21 +93,6 @@ export async function installSseTransport<T>(
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const currentEvent = (input: unknown) => {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location:
|
||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
@@ -155,13 +140,15 @@ export async function installSseTransport<T>(
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
})
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||
const bytes = encoder.encode(
|
||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||
)
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
@@ -174,10 +161,7 @@ export async function installSseTransport<T>(
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (
|
||||
url.origin !== server ||
|
||||
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||
)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(request)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
@@ -193,18 +177,6 @@ export async function installSseTransport<T>(
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
if (url.pathname === "/api/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
if (url.pathname === "/global/event")
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
frame({
|
||||
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||
}),
|
||||
),
|
||||
)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
||||
@@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
id: `resource:${resource.client}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
path: resource.uri,
|
||||
@@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
source: {
|
||||
type: "resource" as const,
|
||||
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
|
||||
clientName: resource.server,
|
||||
clientName: resource.client,
|
||||
uri: resource.uri,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -591,7 +591,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
uri: resource.uri,
|
||||
client: resource.server,
|
||||
client: resource.client,
|
||||
display: resource.name,
|
||||
description: resource.description,
|
||||
mime: resource.mimeType,
|
||||
|
||||
@@ -19,7 +19,6 @@ const optimistic: Array<{
|
||||
}> = []
|
||||
const optimisticSeeded: boolean[] = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||
const sentShell: string[] = []
|
||||
const syncedDirectories: string[] = []
|
||||
@@ -90,27 +89,6 @@ const clientFor = (directory: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
session: {
|
||||
async create(input: { location: { directory: string } }) {
|
||||
await createSessionGate
|
||||
createdSessions.push(input.location.directory)
|
||||
const session = {
|
||||
id: `session-${createdSessions.length}`,
|
||||
title: `New session ${createdSessions.length}`,
|
||||
}
|
||||
sessionDirectories[session.id] = input.location.directory
|
||||
return session
|
||||
},
|
||||
async shell(input: { sessionID: string }) {
|
||||
sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main")
|
||||
},
|
||||
async prompt() {},
|
||||
async command() {},
|
||||
async interrupt() {},
|
||||
},
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
@@ -193,7 +171,6 @@ beforeAll(async () => {
|
||||
const sdk = {
|
||||
scope: "local",
|
||||
directory: "/repo/main",
|
||||
api,
|
||||
client: rootClient,
|
||||
url: "http://localhost:4096",
|
||||
createClient(opts: any) {
|
||||
@@ -288,7 +265,6 @@ beforeEach(() => {
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
|
||||
@@ -20,8 +20,6 @@ import { setCursorPosition } from "./editor-dom"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
type PendingPrompt = {
|
||||
abort: AbortController
|
||||
@@ -41,7 +39,7 @@ export type FollowupDraft = {
|
||||
}
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
client: DirectorySDK["client"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
draft: FollowupDraft
|
||||
@@ -83,21 +81,19 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
const messageID = Identifier.ascending("message")
|
||||
await input.api.command({
|
||||
await input.client.session.command({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
command: cmd,
|
||||
arguments: tail.join(" "),
|
||||
agent: input.draft.agent,
|
||||
model: {
|
||||
id: input.draft.model.modelID,
|
||||
providerID: input.draft.model.providerID,
|
||||
variant: input.draft.variant,
|
||||
},
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||
variant: input.draft.variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
})
|
||||
return true
|
||||
@@ -156,36 +152,13 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
return false
|
||||
}
|
||||
|
||||
await input.api.prompt({
|
||||
await input.client.session.promptAsync({
|
||||
sessionID: input.draft.sessionID,
|
||||
id: messageID,
|
||||
agent: input.draft.agent,
|
||||
model: input.draft.model,
|
||||
messageID,
|
||||
parts: requestParts,
|
||||
variant: input.draft.variant,
|
||||
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: requestParts.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const text = part.source?.text
|
||||
return [
|
||||
{
|
||||
uri: part.url,
|
||||
name: part.filename,
|
||||
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: requestParts.flatMap((part) =>
|
||||
part.type === "agent"
|
||||
? [
|
||||
{
|
||||
name: part.name,
|
||||
mention: part.source
|
||||
? { start: part.source.start, end: part.source.end, text: part.source.value }
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -237,7 +210,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
@@ -263,7 +235,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
.client.session.abort({
|
||||
sessionID,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
@@ -390,13 +364,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
let session = input.info()
|
||||
if (!session && isNewSession) {
|
||||
const created = await sdk()
|
||||
.api.session.create({
|
||||
agent: currentAgent.name,
|
||||
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.then(normalizeSessionInfo)
|
||||
const created = await client.session
|
||||
.create()
|
||||
.then((x) => x.data ?? undefined)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
@@ -480,14 +450,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
|
||||
if (mode === "shell") {
|
||||
clearInput()
|
||||
const eventID = Event.ID.create()
|
||||
sdk()
|
||||
.api.session.shell({
|
||||
client.session
|
||||
.shell({
|
||||
sessionID: session.id,
|
||||
id: eventID,
|
||||
command: text,
|
||||
agent,
|
||||
model,
|
||||
command: text,
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
@@ -505,23 +473,23 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const customCommand = sync().data.command.find((c) => c.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
serverSync().session.set("session_status", session.id, { type: "busy" })
|
||||
sdk()
|
||||
.api.session.command({
|
||||
client.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
id: messageID,
|
||||
command: commandName,
|
||||
arguments: args.join(" "),
|
||||
agent,
|
||||
model: { id: model.modelID, providerID: model.providerID, variant },
|
||||
files: images.map((attachment) => ({
|
||||
uri: attachment.dataUrl,
|
||||
name: attachment.filename,
|
||||
model: `${model.providerID}/${model.modelID}`,
|
||||
variant,
|
||||
parts: images.map((attachment) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file" as const,
|
||||
mime: attachment.mime,
|
||||
url: attachment.dataUrl,
|
||||
filename: attachment.filename,
|
||||
})),
|
||||
})
|
||||
.catch((err) => {
|
||||
serverSync().session.set("session_status", session.id, { type: "idle" })
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -605,7 +573,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
client,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft,
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => {
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false)
|
||||
expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false)
|
||||
})
|
||||
|
||||
test("detects LSP failures that do not block chatting", () => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function hasNonBlockingServiceIssue(input: {
|
||||
mcp: Array<McpServer["status"]["status"]>
|
||||
mcp: Array<McpStatus["status"]>
|
||||
lsp: Array<LspStatus["status"]>
|
||||
}) {
|
||||
return (
|
||||
input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") ||
|
||||
input.mcp.some((status) => status !== "connected" && status !== "disabled") ||
|
||||
input.lsp.some((status) => status === "error")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
|
||||
import type { createServerSdkContext } from "./server-sdk"
|
||||
import type { createServerSyncContextInner } from "./server-sync"
|
||||
import type { State } from "./global-sync/types"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const sessionFields = new Set([
|
||||
@@ -16,7 +15,6 @@ const sessionFields = new Set([
|
||||
"permission",
|
||||
"question",
|
||||
"message",
|
||||
"session_message",
|
||||
"part",
|
||||
"part_text_accum_delta",
|
||||
])
|
||||
@@ -116,6 +114,7 @@ export const createDirSyncContext = (
|
||||
await serverSync.session.sync(sessionID, options)
|
||||
index(sessionID)
|
||||
},
|
||||
diff: serverSync.session.diff,
|
||||
todo: serverSync.session.todo,
|
||||
history: serverSync.session.history,
|
||||
evict(sessionID: string) {
|
||||
@@ -124,9 +123,9 @@ export const createDirSyncContext = (
|
||||
fetch: async (count = 10) => {
|
||||
const [store, setStore] = current()
|
||||
setStore("limit", (value) => value + count)
|
||||
const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" })
|
||||
const sessions = response.data
|
||||
.map(normalizeSessionInfo)
|
||||
const response = await client.session.list()
|
||||
const sessions = (response.data ?? [])
|
||||
.filter((session) => !!session?.id)
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
.slice(0, store.limit)
|
||||
sessions.forEach(serverSync.session.remember)
|
||||
@@ -134,7 +133,7 @@ export const createDirSyncContext = (
|
||||
},
|
||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||
archive: async (sessionID: string) => {
|
||||
await serverSDK.api.session.archive({ sessionID, directory })
|
||||
await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } })
|
||||
current()[1](
|
||||
"session",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const api = {
|
||||
agent: { list: async () => ({ location: {}, data: [] }) },
|
||||
provider: { list: async () => ({ location: {}, data: [] }) },
|
||||
model: {
|
||||
list: async () => ({ location: {}, data: [] }),
|
||||
default: async () => ({ location: {}, data: null }),
|
||||
},
|
||||
permission: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
project: {
|
||||
list: async () => [],
|
||||
current: async () => ({ id: "project", directory: "/project" }),
|
||||
},
|
||||
question: { request: { list: async () => ({ location: {}, data: [] }) } },
|
||||
reference: { list: async () => ({ location: {}, data: [] }) },
|
||||
vcs: { get: async () => ({ location: {}, data: {} }) },
|
||||
} as unknown as ServerApi
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
@@ -66,7 +41,6 @@ function directoryState() {
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
@@ -90,6 +64,7 @@ describe("bootstrapDirectory", () => {
|
||||
sdk: {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: { status: async () => ({ data: {} }) },
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: {
|
||||
list: async () => {
|
||||
@@ -108,7 +83,6 @@ describe("bootstrapDirectory", () => {
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient,
|
||||
api,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
@@ -124,108 +98,79 @@ describe("bootstrapDirectory", () => {
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
projectID: "project",
|
||||
directory: "/project",
|
||||
title: "stale",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
session.remember(stale)
|
||||
session.set("session_status", stale.id, { type: "busy" })
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
session,
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 500
|
||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const client = {} as Parameters<typeof loadPathQuery>[2]
|
||||
const api = {} as CatalogApi
|
||||
const client = {} as OpencodeClient
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
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"])
|
||||
})
|
||||
|
||||
test("loads the current provider and model catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
provider: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["provider", input])
|
||||
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["model", input])
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
default: async (input: unknown) => {
|
||||
calls.push(["default", input])
|
||||
return { location: {}, data: null }
|
||||
},
|
||||
},
|
||||
} as unknown as CatalogApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([
|
||||
["provider", { location: { directory: "/repo" } }],
|
||||
["model", { location: { directory: "/repo" } }],
|
||||
["default", { location: { directory: "/repo" } }],
|
||||
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([
|
||||
"https://debian.example",
|
||||
null,
|
||||
"providers",
|
||||
])
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
})
|
||||
|
||||
test("loads agents from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
} as unknown as AgentApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("loads commands from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: {},
|
||||
data: [{ name: "review", template: "Review files", source: "command" as const }],
|
||||
}
|
||||
},
|
||||
} as unknown as CommandApi
|
||||
|
||||
const result = await loadCommands("/repo", api)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const api = {
|
||||
list: async () => [
|
||||
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
],
|
||||
} as unknown as ProjectApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||
},
|
||||
} as unknown as ReferenceApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,26 +9,6 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
CatalogApi,
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
McpApi,
|
||||
PathGetInput,
|
||||
PathGetOutput,
|
||||
PermissionApi,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
QuestionApi,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
SessionApi,
|
||||
VcsApi,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
@@ -36,20 +16,12 @@ import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import {
|
||||
cmp,
|
||||
normalizeAgentList,
|
||||
normalizePermissionRequest,
|
||||
normalizeProjectInfo,
|
||||
normalizeProviderList,
|
||||
} from "./utils"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { ServerProtocol } from "@/utils/server-protocol"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -116,25 +88,15 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =
|
||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
||||
})
|
||||
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
|
||||
type PathApi = {
|
||||
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
|
||||
}
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, "project"],
|
||||
queryFn: () =>
|
||||
retry(() =>
|
||||
api.list().then((projects) => {
|
||||
return projects
|
||||
sdk.project.list().then((x) => {
|
||||
return (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.map(normalizeProjectInfo)
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
}),
|
||||
@@ -143,8 +105,6 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverSDK: OpencodeClient
|
||||
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
|
||||
protocol?: Promise<ServerProtocol>
|
||||
scope: ServerScope
|
||||
requestFailedTitle: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
@@ -154,14 +114,11 @@ export async function bootstrapGlobal(input: {
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
|
||||
.then((data) => input.setGlobalStore("project", data)),
|
||||
]
|
||||
await runAll(slow)
|
||||
@@ -205,117 +162,44 @@ function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
api: SessionApi
|
||||
sdk: OpencodeClient
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.api.get({ sessionID })).then((session) =>
|
||||
mergeSession(input.setStore, normalizeSessionInfo(session)),
|
||||
),
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
}),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string | null,
|
||||
sdk: CatalogApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
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),
|
||||
sdk.model.list(location),
|
||||
sdk.model.default(location),
|
||||
])
|
||||
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||
}),
|
||||
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
|
||||
})
|
||||
|
||||
type AgentListApi = {
|
||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||
}
|
||||
|
||||
type CommandListApi = {
|
||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||
}
|
||||
|
||||
type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
|
||||
export const loadAgentsQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
sdk: AgentListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? [])
|
||||
return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))
|
||||
}),
|
||||
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
|
||||
})
|
||||
|
||||
export const loadCommands = (
|
||||
directory: string,
|
||||
api: CommandListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
): Promise<CommandInfo[]> =>
|
||||
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: PathApi) =>
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
|
||||
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: ReferenceListApi,
|
||||
legacy?: OpencodeClient,
|
||||
protocol?: Promise<ServerProtocol>,
|
||||
) =>
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () =>
|
||||
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(() => []),
|
||||
queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
@@ -324,18 +208,6 @@ export async function bootstrapDirectory(input: {
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
sdk: OpencodeClient
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
readonly mcp: McpApi
|
||||
readonly path: PathApi
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
vcsCache: VcsCache
|
||||
@@ -349,7 +221,6 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
protocol?: Promise<ServerProtocol>
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
@@ -369,55 +240,66 @@ export async function bootstrapDirectory(input: {
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol))
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
() =>
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
||||
!seededProject &&
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
)),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
|
||||
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
!seededProject &&
|
||||
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
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, input.sdk, input.protocol),
|
||||
),
|
||||
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||
() => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
||||
return input.api.permission.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data.map(normalizePermissionRequest))
|
||||
})().then((permissions) => {
|
||||
const ids = permissions.map((permission) => permission.sessionID)
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.permission ?? input.store.permission
|
||||
@@ -441,19 +323,12 @@ export async function bootstrapDirectory(input: {
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
(async () => {
|
||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
||||
return input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
})().then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
@@ -476,20 +351,17 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
|
||||
input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -255,7 +255,6 @@ export function createChildStoreManager(input: {
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
|
||||
@@ -80,7 +80,6 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
vcs: undefined,
|
||||
limit: 10,
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
...input,
|
||||
@@ -262,8 +261,8 @@ describe("applyDirectoryEvent", () => {
|
||||
|
||||
test("cleans session caches when deleted and decrements only root totals", () => {
|
||||
const cases = [
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true },
|
||||
{ info: rootSession({ id: "ses_1" }), expectedTotal: 1 },
|
||||
{ info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 },
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
@@ -287,10 +286,7 @@ describe("applyDirectoryEvent", () => {
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: item.current ? { sessionID: item.info.id } : { info: item.info },
|
||||
},
|
||||
event: { type: "session.deleted", properties: { info: item.info } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
||||
@@ -8,9 +8,9 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} 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"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -171,11 +171,8 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) break
|
||||
const result = Binary.search(input.store.session, sessionID, (s) => s.id)
|
||||
const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined)
|
||||
const info = (event.properties as { info: Session }).info
|
||||
const result = Binary.search(input.store.session, info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
input.setStore(
|
||||
"session",
|
||||
@@ -184,77 +181,14 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo)
|
||||
if (info?.parentID) break
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.renamed": {
|
||||
const properties = event.properties as { sessionID: string; title: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
title: properties.title,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.usage.updated": {
|
||||
const properties = event.properties as Pick<Session, "cost" | "tokens"> & { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
cost: properties.cost,
|
||||
tokens: properties.tokens,
|
||||
}))
|
||||
break
|
||||
}
|
||||
case "session.archived": {
|
||||
const properties = event.properties as { sessionID: string }
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
cleanupSessionCaches(input.setStore, properties.sessionID)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.moved": {
|
||||
const properties = event.properties as {
|
||||
sessionID: string
|
||||
location: { directory: string; workspaceID?: string }
|
||||
projectID?: string
|
||||
subpath?: string
|
||||
}
|
||||
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
|
||||
if (!result.found) break
|
||||
if (properties.location.directory === input.directory) {
|
||||
input.setStore("session", result.index, (session) => ({
|
||||
...session,
|
||||
projectID: properties.projectID ?? session.projectID,
|
||||
workspaceID: properties.location.workspaceID,
|
||||
directory: properties.location.directory,
|
||||
path: properties.subpath,
|
||||
time: { ...session.time, updated: Date.now() },
|
||||
}))
|
||||
break
|
||||
}
|
||||
const info = input.store.session[result.index]
|
||||
input.setStore(
|
||||
"session",
|
||||
produce((draft) => void draft.splice(result.index, 1)),
|
||||
)
|
||||
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" }))
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
|
||||
@@ -31,24 +31,4 @@ describe("toggleMcp", () => {
|
||||
await toggleMcp(input("disabled"))
|
||||
expect(calls).toEqual(["connect", "refresh"])
|
||||
})
|
||||
|
||||
test("does not toggle a server while its connection is pending", async () => {
|
||||
const calls: string[] = []
|
||||
await toggleMcp({
|
||||
status: "pending",
|
||||
connect: async () => {
|
||||
calls.push("connect")
|
||||
},
|
||||
disconnect: async () => {
|
||||
calls.push("disconnect")
|
||||
},
|
||||
authenticate: async () => {
|
||||
calls.push("authenticate")
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push("refresh")
|
||||
},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { McpServer } from "@opencode-ai/client/promise"
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpServer["status"]["status"]
|
||||
status: McpStatus["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}) {
|
||||
if (input.status === "pending") return
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
needs_auth: input.authenticate,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
@@ -26,10 +33,9 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -39,7 +45,6 @@ describe("app session cache", () => {
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
session_message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
@@ -62,10 +67,9 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -75,7 +79,6 @@ describe("app session cache", () => {
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
session_message: {},
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
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"
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
@@ -32,7 +37,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_message[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type { RootLoadArgs } from "./types"
|
||||
|
||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||
const result = await input.api.list({
|
||||
directory: input.directory,
|
||||
parentID: null,
|
||||
limit: input.limit,
|
||||
order: "desc",
|
||||
})
|
||||
return {
|
||||
data: result.data.map(normalizeSessionInfo),
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
}
|
||||
|
||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
try {
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return { data: result.data, limit: input.limit, limited: true } as const
|
||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} as const
|
||||
} catch {
|
||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
||||
return { data: result.data, limit: input.limit, limited: false } as const
|
||||
const result = await input.list({ directory: input.directory, roots: true })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: false,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
LspStatus,
|
||||
McpResource,
|
||||
McpStatus,
|
||||
Message,
|
||||
Part,
|
||||
Path,
|
||||
@@ -10,12 +13,11 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} 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"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
||||
@@ -33,7 +35,7 @@ export type ProjectMeta = {
|
||||
export type State = {
|
||||
status: "loading" | "partial" | "complete"
|
||||
agent: Agent[]
|
||||
command: CommandInfo[]
|
||||
command: Command[]
|
||||
reference: ReferenceInfo[]
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
@@ -49,7 +51,7 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
@@ -62,7 +64,7 @@ export type State = {
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpServer["status"]
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
mcp_resource: {
|
||||
[key: string]: McpResource
|
||||
@@ -74,9 +76,6 @@ export type State = {
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
session_message: {
|
||||
[sessionID: string]: SessionMessageInfo[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
@@ -129,6 +128,18 @@ export type DisposeCheck = {
|
||||
loadingSessions: boolean
|
||||
}
|
||||
|
||||
export type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
}
|
||||
|
||||
export type RootLoadResult = {
|
||||
data?: Session[]
|
||||
limit: number
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
export const MAX_DIR_STORES = 30
|
||||
export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||
export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
|
||||
|
||||
@@ -1,117 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { directoryKey, normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("adapts current agents to the app agent shape", () => {
|
||||
const result = normalizeAgentList([
|
||||
{
|
||||
id: "build",
|
||||
name: "Build",
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
color: "primary",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} },
|
||||
system: "Build software",
|
||||
permissions: [{ action: "read", resource: "*", effect: "allow" }],
|
||||
},
|
||||
] as AgentListOutput["data"])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "build",
|
||||
description: undefined,
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
temperature: 0.2,
|
||||
topP: 0.9,
|
||||
color: "primary",
|
||||
permission: [{ permission: "read", pattern: "*", action: "allow" }],
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
variant: "high",
|
||||
prompt: "Build software",
|
||||
options: { temperature: 0.2, topP: 0.9 },
|
||||
steps: undefined,
|
||||
},
|
||||
])
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizePermissionRequest", () => {
|
||||
test("adapts the current permission request to app state", () => {
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
expect(
|
||||
normalizePermissionRequest({
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
action: "read",
|
||||
resources: ["README.md"],
|
||||
save: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
source: { type: "tool", messageID: "message-1", callID: "call-1" },
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
}),
|
||||
).toEqual({
|
||||
id: "permission-1",
|
||||
sessionID: "session-1",
|
||||
permission: "read",
|
||||
patterns: ["README.md"],
|
||||
always: ["*.md"],
|
||||
metadata: { path: "README.md" },
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
})
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeProviderList", () => {
|
||||
test("groups current models into the app provider catalog", () => {
|
||||
const result = normalizeProviderList(
|
||||
[{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"],
|
||||
[
|
||||
{
|
||||
id: "gpt-5",
|
||||
modelID: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "GPT-5",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
variants: [{ id: "high" }],
|
||||
time: { released: 1 },
|
||||
cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
},
|
||||
{
|
||||
id: "gpt-old",
|
||||
modelID: "gpt-old",
|
||||
providerID: "openai",
|
||||
name: "GPT Old",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
status: "deprecated",
|
||||
enabled: true,
|
||||
limit: { context: 1, output: 1 },
|
||||
},
|
||||
] as ModelListOutput["data"],
|
||||
{ id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"],
|
||||
)
|
||||
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
expect(result.default).toEqual({ openai: "gpt-5" })
|
||||
expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined()
|
||||
expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({
|
||||
id: "gpt-5",
|
||||
providerID: "openai",
|
||||
capabilities: { toolcall: true, attachment: true },
|
||||
cost: { input: 1, output: 2 },
|
||||
variants: { high: {} },
|
||||
})
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,152 +1,39 @@
|
||||
import type {
|
||||
AgentListOutput,
|
||||
ModelDefaultOutput,
|
||||
ModelListOutput,
|
||||
PermissionV2Request,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] {
|
||||
if (input.every((agent) => !("request" in agent))) return input as Agent[]
|
||||
return (input as AgentListOutput["data"]).map((agent) => ({
|
||||
name: agent.id,
|
||||
description: agent.description,
|
||||
mode: agent.mode,
|
||||
hidden: agent.hidden,
|
||||
temperature:
|
||||
typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined,
|
||||
topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined,
|
||||
color: agent.color,
|
||||
permission: agent.permissions.map((rule) => ({
|
||||
permission: rule.action,
|
||||
pattern: rule.resource,
|
||||
action: rule.effect,
|
||||
})),
|
||||
model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id },
|
||||
variant: agent.model?.variant,
|
||||
prompt: agent.system,
|
||||
options: agent.request.settings,
|
||||
steps: agent.steps,
|
||||
}))
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
|
||||
export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest {
|
||||
if ("permission" in input) return input
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
|
||||
return {
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
permission: input.action,
|
||||
patterns: input.resources,
|
||||
always: input.save ?? [],
|
||||
metadata: input.metadata ?? {},
|
||||
tool:
|
||||
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviderList(
|
||||
providers: ProviderListOutput["data"] | ProviderListResponse,
|
||||
models?: ModelListOutput["data"],
|
||||
defaultModel?: ModelDefaultOutput["data"],
|
||||
): NormalizedProviderListResponse {
|
||||
if (!Array.isArray(providers)) {
|
||||
return {
|
||||
...providers,
|
||||
all: new Map(
|
||||
providers.all.map((provider) => [
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
]),
|
||||
...input,
|
||||
all: new Map(
|
||||
input.all.map(
|
||||
(provider) =>
|
||||
[
|
||||
provider.id,
|
||||
{
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
}
|
||||
}
|
||||
const all = new Map<string, Provider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
all.set(provider.id, {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: provider.settings ?? {},
|
||||
models: {},
|
||||
})
|
||||
}
|
||||
|
||||
for (const model of models ?? []) {
|
||||
const provider = all.get(model.providerID)
|
||||
if (!provider || model.status === "deprecated") continue
|
||||
const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0]
|
||||
provider.models[model.id] = {
|
||||
id: model.id,
|
||||
providerID: model.providerID,
|
||||
api: {
|
||||
id: model.modelID,
|
||||
url: "",
|
||||
npm: model.package ?? provider.id,
|
||||
},
|
||||
name: model.name,
|
||||
family: model.family,
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: false,
|
||||
attachment: model.capabilities.input.some((item) => item !== "text"),
|
||||
toolcall: model.capabilities.tools,
|
||||
input: {
|
||||
text: model.capabilities.input.includes("text"),
|
||||
audio: model.capabilities.input.includes("audio"),
|
||||
image: model.capabilities.input.includes("image"),
|
||||
video: model.capabilities.input.includes("video"),
|
||||
pdf: model.capabilities.input.includes("pdf"),
|
||||
},
|
||||
output: {
|
||||
text: model.capabilities.output.includes("text"),
|
||||
audio: model.capabilities.output.includes("audio"),
|
||||
image: model.capabilities.output.includes("image"),
|
||||
video: model.capabilities.output.includes("video"),
|
||||
pdf: model.capabilities.output.includes("pdf"),
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: cost?.input ?? 0,
|
||||
output: cost?.output ?? 0,
|
||||
cache: {
|
||||
read: cost?.cache.read ?? 0,
|
||||
write: cost?.cache.write ?? 0,
|
||||
},
|
||||
},
|
||||
limit: model.limit,
|
||||
status: model.status,
|
||||
options: model.settings ?? {},
|
||||
headers: model.headers ?? {},
|
||||
release_date: new Date(model.time.released).toISOString().slice(0, 10),
|
||||
variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all,
|
||||
connected: providers.map((provider) => provider.id),
|
||||
default: Object.fromEntries(
|
||||
providers.flatMap((provider) => {
|
||||
const model =
|
||||
defaultModel?.providerID === provider.id
|
||||
? defaultModel
|
||||
: models?.find((item) => item.providerID === provider.id && item.status !== "deprecated")
|
||||
return model ? [[provider.id, model.id]] : []
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -162,10 +49,3 @@ export function sanitizeProject(project: Project) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectInfo(project: Project | CurrentProject): Project {
|
||||
return {
|
||||
...project,
|
||||
vcs: project.vcs === "git" ? "git" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
@@ -15,23 +14,6 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves V2 events while adapting permission requests for existing consumers", () => {
|
||||
const current = {
|
||||
id: "evt_1",
|
||||
created: 1,
|
||||
type: "permission.v2.asked",
|
||||
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
type: "permission.asked",
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
@@ -52,24 +34,6 @@ describe("coalesceServerEvents", () => {
|
||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("merges adjacent current text deltas", () => {
|
||||
const current = (id: string, value: string) =>
|
||||
adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "hello ") },
|
||||
{ directory: "/repo", payload: current("evt_2", "world") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
const status = {
|
||||
directory: "/repo",
|
||||
|
||||
@@ -1,60 +1,21 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
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"
|
||||
import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server"
|
||||
import { createSdkForServer } from "@/utils/server"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
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"
|
||||
|
||||
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 & { current?: OpenCodeEvent }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
if (event.type === "permission.v2.asked") {
|
||||
return {
|
||||
id: event.id,
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: event.data.id,
|
||||
sessionID: event.data.sessionID,
|
||||
permission: event.data.action,
|
||||
patterns: event.data.resources,
|
||||
always: event.data.save ?? [],
|
||||
metadata: event.data.metadata ?? {},
|
||||
tool:
|
||||
event.data.source?.type === "tool"
|
||||
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
|
||||
: undefined,
|
||||
},
|
||||
current: event,
|
||||
} as ServerEvent
|
||||
}
|
||||
if (event.type === "permission.v2.replied")
|
||||
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.asked")
|
||||
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.replied")
|
||||
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.rejected")
|
||||
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
@@ -79,34 +40,6 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.payload.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.payload.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
previous.directory === event.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
const data =
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent,
|
||||
}
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
output.push(event)
|
||||
return
|
||||
@@ -138,52 +71,12 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
return output
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
protocol: Promise<ServerProtocol>
|
||||
url: string
|
||||
client: ReturnType<typeof createSdkForServer>
|
||||
api: CompatibleApi
|
||||
currentApi: ServerApi
|
||||
event: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
start: () => Promise<void> | undefined
|
||||
}
|
||||
createClient: (
|
||||
opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">,
|
||||
) => ReturnType<typeof createSdkForServer>
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
|
||||
@@ -198,15 +91,13 @@ 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 emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
[key: string]: Event
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
@@ -251,6 +142,21 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const HEARTBEAT_TIMEOUT_MS = 15_000
|
||||
let lastEventAt = Date.now()
|
||||
let heartbeat: ReturnType<typeof setTimeout> | undefined
|
||||
const resetHeartbeat = () => {
|
||||
lastEventAt = Date.now()
|
||||
if (heartbeat) clearTimeout(heartbeat)
|
||||
heartbeat = setTimeout(() => {
|
||||
attempt?.abort()
|
||||
}, HEARTBEAT_TIMEOUT_MS)
|
||||
}
|
||||
const clearHeartbeat = () => {
|
||||
if (!heartbeat) return
|
||||
clearTimeout(heartbeat)
|
||||
heartbeat = undefined
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (started) return run
|
||||
@@ -262,24 +168,35 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
attempt = new AbortController()
|
||||
lastEventAt = Date.now()
|
||||
const onAbort = () => {
|
||||
attempt?.abort()
|
||||
}
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
try {
|
||||
const kind = await protocol
|
||||
const events =
|
||||
kind === "v1"
|
||||
? (await eventSdk.global.event({ signal: attempt.signal })).stream
|
||||
: eventApi.event.subscribe({ signal: attempt.signal })
|
||||
const events = await eventSdk.global.event({
|
||||
signal: attempt.signal,
|
||||
onSseError: (error) => {
|
||||
if (isStreamClosed(error, attempt?.signal)) return
|
||||
if (streamErrorLogged) return
|
||||
streamErrorLogged = true
|
||||
console.error("[global-sdk] event stream error", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
error,
|
||||
})
|
||||
},
|
||||
})
|
||||
let yielded = Date.now()
|
||||
for await (const event of events) {
|
||||
resetHeartbeat()
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
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 (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
@@ -297,6 +214,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
} finally {
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
attempt = undefined
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
@@ -315,11 +233,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
clearHeartbeat()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||
makeEventListener(document, "visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible") return
|
||||
if (!started) return
|
||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||
attempt?.abort()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -333,24 +258,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
})
|
||||
const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const legacy = (directory?: string) =>
|
||||
createSdkForServer({
|
||||
server: server.http,
|
||||
fetch: platform.fetch,
|
||||
throwOnError: true,
|
||||
directory,
|
||||
})
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
protocol,
|
||||
url: server.http.url,
|
||||
client: sdk,
|
||||
api,
|
||||
currentApi,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
listen: emitter.listen.bind(emitter),
|
||||
@@ -366,6 +279,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
}
|
||||
}
|
||||
|
||||
type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
@@ -395,7 +309,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
|
||||
})
|
||||
|
||||
type SDKEventMap = {
|
||||
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}
|
||||
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
@@ -415,12 +329,6 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
scope: serverSDK.scope,
|
||||
directory,
|
||||
client,
|
||||
api: createCompatibleApi({
|
||||
protocol: serverSDK.protocol,
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
}),
|
||||
event: emitter,
|
||||
get url() {
|
||||
return serverSDK.url
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { createV2SessionReducer } from "./server-session-v2-reducer"
|
||||
|
||||
const event = (input: object) => input as OpenCodeEvent
|
||||
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
|
||||
|
||||
describe("v2 session reducer", () => {
|
||||
test("projects promoted input and streaming assistant content", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
return result
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_admitted",
|
||||
type: "session.input.admitted",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_user",
|
||||
input: { type: "user", delivery: "steer", data: { text: "hello" } },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_start",
|
||||
type: "session.text.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_delta",
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_text_end",
|
||||
type: "session.text.ended",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" },
|
||||
})
|
||||
|
||||
expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" })
|
||||
expect(messages[1]).toMatchObject({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("folds tool, retry, and completion events", () => {
|
||||
const reducer = createV2SessionReducer()
|
||||
let messages: SessionMessageInfo[] = []
|
||||
const apply = (input: object) => {
|
||||
const result = reducer.reduce(messages, event(input))
|
||||
if (result) messages = result.messages
|
||||
}
|
||||
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_step",
|
||||
type: "session.step.started",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_start",
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_delta",
|
||||
type: "session.tool.input.delta",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_called",
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_tool_success",
|
||||
type: "session.tool.success",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
callID: "call_1",
|
||||
structured: {},
|
||||
content: [{ type: "text", text: "done" }],
|
||||
executed: true,
|
||||
},
|
||||
})
|
||||
apply({
|
||||
...base,
|
||||
id: "evt_retry",
|
||||
type: "session.retry.scheduled",
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
attempt: 2,
|
||||
at: 10,
|
||||
error: { type: "ProviderError", message: "retry" },
|
||||
},
|
||||
})
|
||||
apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } })
|
||||
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: "assistant",
|
||||
retry: undefined,
|
||||
content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("requests hydration when promotion admission was missed", () => {
|
||||
const result = createV2SessionReducer().reduce(
|
||||
[],
|
||||
event({
|
||||
...base,
|
||||
id: "evt_promoted",
|
||||
type: "session.input.promoted",
|
||||
data: { sessionID: "ses_1", inputID: "msg_user" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
|
||||
})
|
||||
})
|
||||
@@ -1,502 +0,0 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
|
||||
|
||||
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
|
||||
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
type Shell = Extract<SessionMessageInfo, { type: "shell" }>
|
||||
|
||||
export type V2SessionReduction = {
|
||||
sessionID: string
|
||||
messages: SessionMessageInfo[]
|
||||
touched: string[]
|
||||
missing?: string
|
||||
}
|
||||
|
||||
export function createV2SessionReducer() {
|
||||
const pending = new Map<string, SessionPendingMessage>()
|
||||
|
||||
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({
|
||||
sessionID,
|
||||
messages,
|
||||
touched,
|
||||
})
|
||||
const append = (message: SessionMessageInfo) =>
|
||||
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
|
||||
|
||||
switch (event.type) {
|
||||
case "session.input.admitted":
|
||||
pending.set(key(sessionID, event.data.inputID), event.data.input)
|
||||
return result([...source])
|
||||
case "session.input.promoted": {
|
||||
const input = pending.get(key(sessionID, event.data.inputID))
|
||||
pending.delete(key(sessionID, event.data.inputID))
|
||||
if (!input) return { ...result([...source]), missing: event.data.inputID }
|
||||
if (input.type === "user")
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
type: "user",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
files: input.data.files,
|
||||
agents: input.data.agents,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return append({
|
||||
id: event.data.inputID,
|
||||
type: "synthetic",
|
||||
metadata: input.data.metadata,
|
||||
text: input.data.text,
|
||||
description: input.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
}
|
||||
case "session.agent.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "agent-switched",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.model.selected":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "model-switched",
|
||||
metadata: event.metadata,
|
||||
model: event.data.model,
|
||||
previous: source.findLast(
|
||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||
item.type === "model-switched" || item.type === "assistant",
|
||||
)?.model,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.synthetic":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "synthetic",
|
||||
metadata: event.data.metadata,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.skill.activated":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "skill",
|
||||
metadata: event.metadata,
|
||||
skill: event.data.id,
|
||||
name: event.data.name,
|
||||
text: event.data.text,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.started":
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "shell",
|
||||
metadata: event.metadata,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.shell.ended":
|
||||
return updateMessage<Shell>(
|
||||
source,
|
||||
(item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id,
|
||||
(item) => ({
|
||||
...item,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
output: event.data.output,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.step.started": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
const completed =
|
||||
current && current.id !== event.data.assistantMessageID
|
||||
? update(source, current.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? { ...item, retry: undefined, time: { ...item.time, completed: event.created } }
|
||||
: item,
|
||||
)
|
||||
: [...source]
|
||||
const existing = completed.find((item) => item.id === event.data.assistantMessageID)
|
||||
if (existing?.type === "assistant")
|
||||
return result(
|
||||
update(completed, existing.id, (item) =>
|
||||
item.type === "assistant"
|
||||
? {
|
||||
...item,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
retry: undefined,
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot,
|
||||
time: { ...item.time, completed: undefined },
|
||||
}
|
||||
: item,
|
||||
),
|
||||
current && current.id !== existing.id ? [current.id, existing.id] : [existing.id],
|
||||
)
|
||||
return result(
|
||||
[
|
||||
...completed,
|
||||
{
|
||||
id: event.data.assistantMessageID,
|
||||
type: "assistant",
|
||||
metadata: event.metadata,
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
content: [],
|
||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID],
|
||||
)
|
||||
}
|
||||
case "session.step.ended":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: event.data.finish,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.step.failed":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
finish: "error",
|
||||
error: event.data.error,
|
||||
retry: undefined,
|
||||
cost: event.data.cost ?? item.cost,
|
||||
tokens: event.data.tokens ?? item.tokens,
|
||||
snapshot:
|
||||
event.data.snapshot || event.data.files
|
||||
? { ...item.snapshot, end: event.data.snapshot, files: event.data.files }
|
||||
: item.snapshot,
|
||||
time: { ...item.time, completed: event.created },
|
||||
}))
|
||||
case "session.text.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }),
|
||||
}))
|
||||
case "session.text.delta":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}))
|
||||
case "session.text.ended":
|
||||
return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
}))
|
||||
case "session.reasoning.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: insertOrdinal(item.content, "reasoning", event.data.ordinal, {
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
}))
|
||||
case "session.reasoning.delta":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: item.text + event.data.delta,
|
||||
}),
|
||||
)
|
||||
case "session.reasoning.ended":
|
||||
return updateContent(
|
||||
source,
|
||||
event.data.assistantMessageID,
|
||||
sessionID,
|
||||
"reasoning",
|
||||
event.data.ordinal,
|
||||
(item) => ({
|
||||
...item,
|
||||
text: event.data.text,
|
||||
state: event.data.state ?? item.state,
|
||||
time: { created: item.time?.created ?? event.created, completed: event.created },
|
||||
}),
|
||||
)
|
||||
case "session.tool.input.started":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
|
||||
? item.content
|
||||
: [
|
||||
...item.content,
|
||||
{
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: event.created },
|
||||
},
|
||||
],
|
||||
}))
|
||||
case "session.tool.input.delta":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming"
|
||||
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.input.ended":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
|
||||
)
|
||||
case "session.tool.called":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
|
||||
...tool,
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
time: { ...tool.time, ran: event.created },
|
||||
}))
|
||||
case "session.tool.progress":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
|
||||
tool.state.status === "running"
|
||||
? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
|
||||
: tool,
|
||||
)
|
||||
case "session.tool.success":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: tool.state.input,
|
||||
structured: event.data.structured,
|
||||
content: event.data.content,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.tool.failed":
|
||||
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
|
||||
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
|
||||
return {
|
||||
...tool,
|
||||
executed: event.data.executed || tool.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
state: {
|
||||
status: "error",
|
||||
input: typeof tool.state.input === "string" ? {} : tool.state.input,
|
||||
structured: tool.state.status === "running" ? tool.state.structured : {},
|
||||
content: tool.state.status === "running" ? tool.state.content : [],
|
||||
error: event.data.error,
|
||||
result: event.data.result,
|
||||
},
|
||||
time: { ...tool.time, completed: event.created },
|
||||
}
|
||||
})
|
||||
case "session.retry.scheduled":
|
||||
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
|
||||
...item,
|
||||
retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error },
|
||||
}))
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
case "session.execution.interrupted": {
|
||||
const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed)
|
||||
if (!current?.retry) return result([...source])
|
||||
return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined }))
|
||||
}
|
||||
case "session.compaction.started":
|
||||
return append({
|
||||
id: event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
case "session.compaction.delta":
|
||||
return updateMessage<Extract<Compaction, { status: "running" }>>(
|
||||
source,
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
(item) => ({
|
||||
...item,
|
||||
summary: item.summary + event.data.text,
|
||||
}),
|
||||
sessionID,
|
||||
)
|
||||
case "session.compaction.ended": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
if (!current)
|
||||
return append({
|
||||
id: messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
metadata: event.metadata,
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
time: { created: event.created },
|
||||
})
|
||||
return result(
|
||||
update(source, current.id, () => ({
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
})),
|
||||
[current.id],
|
||||
)
|
||||
}
|
||||
case "session.compaction.failed": {
|
||||
const current = source.findLast(
|
||||
(item): item is Extract<Compaction, { status: "running" }> =>
|
||||
item.type === "compaction" && item.status === "running",
|
||||
)
|
||||
const failed: Extract<Compaction, { status: "failed" }> = {
|
||||
id: current?.id ?? event.data.inputID ?? messageID(event.id),
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
time: current?.time ?? { created: event.created },
|
||||
}
|
||||
if (!current) return append(failed)
|
||||
return result(
|
||||
update(source, current.id, () => failed),
|
||||
[failed.id],
|
||||
)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reduce,
|
||||
clear(sessionID: string) {
|
||||
for (const id of pending.keys()) {
|
||||
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function key(sessionID: string, inputID: string) {
|
||||
return `${sessionID}:${inputID}`
|
||||
}
|
||||
|
||||
function messageID(eventID: string) {
|
||||
return eventID.replace(/^evt_/, "msg_")
|
||||
}
|
||||
|
||||
function update(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
apply: (item: SessionMessageInfo) => SessionMessageInfo,
|
||||
) {
|
||||
return source.map((item) => (item.id === id ? apply(item) : item))
|
||||
}
|
||||
|
||||
function updateMessage<T extends SessionMessageInfo>(
|
||||
source: readonly SessionMessageInfo[],
|
||||
matches: (item: SessionMessageInfo) => item is T,
|
||||
apply: (item: T) => T,
|
||||
sessionID: string,
|
||||
): V2SessionReduction {
|
||||
const current = source.findLast(matches)
|
||||
if (!current) return { sessionID, messages: [...source], touched: [] }
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)),
|
||||
touched: [current.id],
|
||||
}
|
||||
}
|
||||
|
||||
function updateAssistant(
|
||||
source: readonly SessionMessageInfo[],
|
||||
id: string,
|
||||
sessionID: string,
|
||||
apply: (item: Assistant) => Assistant,
|
||||
): V2SessionReduction {
|
||||
return {
|
||||
sessionID,
|
||||
messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)),
|
||||
touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [],
|
||||
}
|
||||
}
|
||||
|
||||
function updateContent<T extends "text" | "reasoning">(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
sessionID: string,
|
||||
type: T,
|
||||
ordinal: number,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) => Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => {
|
||||
let index = -1
|
||||
return {
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => {
|
||||
if (item.type !== type || ++index !== ordinal) return item
|
||||
return apply(item as Extract<Assistant["content"][number], { type: T }>)
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function updateTool(
|
||||
source: readonly SessionMessageInfo[],
|
||||
messageID: string,
|
||||
callID: string,
|
||||
sessionID: string,
|
||||
apply: (
|
||||
item: Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) => Extract<Assistant["content"][number], { type: "tool" }>,
|
||||
) {
|
||||
return updateAssistant(source, messageID, sessionID, (assistant) => ({
|
||||
...assistant,
|
||||
content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)),
|
||||
}))
|
||||
}
|
||||
|
||||
function insertOrdinal<T extends Assistant["content"][number]["type"]>(
|
||||
source: Assistant["content"],
|
||||
type: T,
|
||||
ordinal: number,
|
||||
item: Extract<Assistant["content"][number], { type: T }>,
|
||||
) {
|
||||
const matches = source.filter((content) => content.type === type)
|
||||
if (matches[ordinal]) return source
|
||||
return [...source, item]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
|
||||
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
@@ -159,57 +158,6 @@ function setup(sessions: Record<string, Session>) {
|
||||
}
|
||||
|
||||
describe("server session", () => {
|
||||
test("projects V2 session events into current and legacy message state", () => {
|
||||
const ctx = setup({ child: session("child") })
|
||||
ctx.store.remember(session("child"))
|
||||
ctx.store.set("session_message", "child", [
|
||||
{
|
||||
id: "msg_1_user",
|
||||
type: "user",
|
||||
text: "hello",
|
||||
time: { created: 1 },
|
||||
},
|
||||
])
|
||||
const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
|
||||
|
||||
apply({
|
||||
id: "evt_step",
|
||||
created: 2,
|
||||
type: "session.step.started",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: {
|
||||
sessionID: "child",
|
||||
assistantMessageID: "msg_2_assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
apply({
|
||||
id: "evt_text_start",
|
||||
created: 3,
|
||||
type: "session.text.started",
|
||||
durable: { aggregateID: "child", seq: 2, version: 1 },
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
|
||||
})
|
||||
apply({
|
||||
id: "evt_text_delta",
|
||||
created: 4,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
|
||||
})
|
||||
|
||||
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
|
||||
id: "msg_2_assistant",
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "world" }],
|
||||
})
|
||||
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
|
||||
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
|
||||
})
|
||||
|
||||
test("resolves lineage by session ID without directory", async () => {
|
||||
const ctx = setup({ child: session("child", "root"), root: session("root") })
|
||||
|
||||
@@ -230,111 +178,6 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("loads current session content through the current message API", async () => {
|
||||
const requests: unknown[] = []
|
||||
const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
|
||||
const assistant = {
|
||||
id: "msg_a_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
time: { created: 2, completed: 3 },
|
||||
}
|
||||
const client = {
|
||||
session: {
|
||||
messages: () => {
|
||||
throw new Error("legacy message endpoint called")
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
const messageApi = {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
return { data: [assistant, user], cursor: { previous: null, next: null } }
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("reprojects current assistants when an older page supplies their user", async () => {
|
||||
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
||||
const assistant = (id: string, created: number) => ({
|
||||
id,
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text" as const, text: id }],
|
||||
time: { created, completed: created },
|
||||
})
|
||||
const assistants = [
|
||||
assistant("msg_2_assistant", 2),
|
||||
assistant("msg_3_assistant", 3),
|
||||
assistant("msg_4_assistant", 4),
|
||||
]
|
||||
const pages = [
|
||||
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
|
||||
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
|
||||
]
|
||||
const messageApi = {
|
||||
list: async () => pages.shift()!,
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
expect(store.data.message.root).toEqual([])
|
||||
|
||||
await store.history.loadMore("root")
|
||||
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||
user.id,
|
||||
...assistants.map((item) => item.id),
|
||||
])
|
||||
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
|
||||
})
|
||||
|
||||
test("indexes V1 messages for the current timeline projection", async () => {
|
||||
const user = userMessage("message-1", { sessionID: "root" })
|
||||
const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
|
||||
const client = messageClient(
|
||||
response([
|
||||
{ info: user, parts: [textPart(user.id, { sessionID: "root" })] },
|
||||
{ info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
|
||||
]),
|
||||
)
|
||||
const messageApi = {
|
||||
list: () => {
|
||||
throw new Error("current message endpoint called")
|
||||
},
|
||||
} as unknown as MessageApi
|
||||
const store = createServerSession(client, {} as SessionApi, messageApi, {
|
||||
protocol: Promise.resolve("v1"),
|
||||
})
|
||||
store.remember(session("root"))
|
||||
|
||||
await store.sync("root")
|
||||
|
||||
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
expect(store.data.session_message.root).toMatchObject([
|
||||
{ id: user.id, type: "user", text: "text" },
|
||||
{ id: assistant.id, type: "assistant" },
|
||||
])
|
||||
|
||||
const next = userMessage("message-3", { sessionID: "root" })
|
||||
store.apply({ type: "message.updated", properties: { info: next } })
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
|
||||
|
||||
store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
|
||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
Message,
|
||||
OpencodeClient,
|
||||
@@ -9,18 +8,15 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} 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"
|
||||
import { message as cleanMessage } from "@/utils/diffs"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
import { normalizeSessionInfo } from "@/utils/session"
|
||||
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
@@ -40,37 +36,10 @@ type OptimisticItem = {
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
source?: SessionMessageInfo[]
|
||||
sourceMode?: "latest" | "older"
|
||||
projectSource?: boolean
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.info.id, b.info.id))
|
||||
.map((item) => {
|
||||
if (item.info.role === "user") {
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "user" as const,
|
||||
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
time: item.info.time,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: item.info.id,
|
||||
type: "assistant" as const,
|
||||
agent: item.info.agent ?? item.info.mode,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: [],
|
||||
time: item.info.time,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
@@ -83,7 +52,6 @@ type MessageLoadState = {
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
touchedSource: Set<string>
|
||||
}
|
||||
|
||||
type MessageLoadBaseline = Pick<
|
||||
@@ -169,25 +137,15 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||
messageApi?: MessageApi,
|
||||
currentOptions?: ServerSessionOptions,
|
||||
) {
|
||||
const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined
|
||||
const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined)
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
session_message: {} as Record<string, SessionMessageInfo[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
session_working(id: string) {
|
||||
@@ -196,9 +154,9 @@ export function createServerSession(
|
||||
})
|
||||
const requests = new Map<string, Promise<Session>>()
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const v2 = createV2SessionReducer()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
@@ -233,16 +191,6 @@ export function createServerSession(
|
||||
at: {} as Record<string, number | undefined>,
|
||||
})
|
||||
|
||||
const indexLegacyMessage = (message: Message) => {
|
||||
const current = data.session_message[message.sessionID] ?? []
|
||||
if (current.some((item) => item.id === message.id)) return
|
||||
setData(
|
||||
"session_message",
|
||||
message.sessionID,
|
||||
reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]),
|
||||
)
|
||||
}
|
||||
|
||||
const remember = (session: Session) => {
|
||||
setData("info", session.id, reconcile(session))
|
||||
infoSeen.delete(session.id)
|
||||
@@ -252,6 +200,7 @@ export function createServerSession(
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
@@ -293,31 +242,27 @@ export function createServerSession(
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = sessionApi
|
||||
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
|
||||
: client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
return result.data
|
||||
})
|
||||
const resolved = request.then((result) => {
|
||||
if (generations.get(sessionID) !== active) return result
|
||||
return remember(result)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
requests.set(sessionID, resolved)
|
||||
requests.set(sessionID, request)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
if (
|
||||
generations.get(sessionID) === active &&
|
||||
!data.info[sessionID] &&
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
void resolved.then(cleanup, cleanup)
|
||||
return resolved
|
||||
void request.then(cleanup, cleanup)
|
||||
return request
|
||||
}
|
||||
|
||||
const peekLineage = (sessionID: string) => {
|
||||
@@ -474,9 +419,9 @@ export function createServerSession(
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
v2.clear(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
removedMessages.delete(sessionID)
|
||||
@@ -504,6 +449,7 @@ export function createServerSession(
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
@@ -524,25 +470,6 @@ export function createServerSession(
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
if (messageApi && (await options?.protocol) !== "v1") {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
|
||||
})
|
||||
const source = [...response.data].reverse()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
source,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
projectSource: true,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length === 0,
|
||||
}
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
@@ -554,24 +481,12 @@ export function createServerSession(
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
})),
|
||||
source: legacyMessageSource(items),
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
cursor: response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.headers.get("x-next-cursor"),
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||
if (sessionApi && (await options?.protocol) !== "v1") {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return sessionApi.message({ sessionID, messageID })
|
||||
})
|
||||
const normalized = normalizeSessionMessages(sessionID, [response])
|
||||
const message = normalized.messages[0]
|
||||
if (!message) throw new Error(`Message not found: ${messageID}`)
|
||||
return { message, parts: normalized.parts.get(messageID) ?? [] }
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
@@ -656,31 +571,7 @@ export function createServerSession(
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const source = page.source
|
||||
? (() => {
|
||||
const incoming = new Map(page.source.map((message) => [message.id, message]))
|
||||
const existing = data.session_message[sessionID] ?? []
|
||||
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),
|
||||
)
|
||||
})()
|
||||
: undefined
|
||||
const projected =
|
||||
page.projectSource && source
|
||||
? (() => {
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
...page,
|
||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||
part: [...normalized.parts.entries()]
|
||||
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
|
||||
.sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
})()
|
||||
: page
|
||||
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
@@ -692,7 +583,6 @@ export function createServerSession(
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
if (source) setData("session_message", sessionID, reconcile(source))
|
||||
const messageIDs = replaceMessages(sessionID, messages)
|
||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||
const orphans = orphanParts.get(sessionID)
|
||||
@@ -723,7 +613,6 @@ export function createServerSession(
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
touchedSource: new Set(),
|
||||
}
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
@@ -858,109 +747,6 @@ export function createServerSession(
|
||||
return properties.part.sessionID
|
||||
}
|
||||
|
||||
const projectV2 = (reduction: V2SessionReduction) => {
|
||||
reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID))
|
||||
setData("session_message", reduction.sessionID, reconcile(reduction.messages))
|
||||
if (reduction.touched.length === 0) return
|
||||
|
||||
const touched = new Set(reduction.touched)
|
||||
let parentID: string | undefined
|
||||
for (const message of reduction.messages) {
|
||||
if (message.type === "user" || (message.type === "synthetic" && message.description?.trim()))
|
||||
parentID = message.id
|
||||
if (message.type === "shell") {
|
||||
if (touched.has(message.id)) touched.add(`${message.id}:assistant`)
|
||||
parentID = undefined
|
||||
}
|
||||
if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID)
|
||||
if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID)
|
||||
}
|
||||
|
||||
const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages)
|
||||
batch(() => {
|
||||
for (const message of normalized.messages) {
|
||||
if (!touched.has(message.id)) continue
|
||||
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
|
||||
}
|
||||
for (const messageID of touched) {
|
||||
const next = normalized.parts.get(messageID) ?? []
|
||||
const nextIDs = new Set(next.map((part) => part.id))
|
||||
for (const part of next) {
|
||||
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
|
||||
}
|
||||
for (const part of data.part[messageID] ?? []) {
|
||||
if (nextIDs.has(part.id)) continue
|
||||
apply({
|
||||
type: "message.part.removed",
|
||||
properties: { sessionID: reduction.sessionID, messageID, partID: part.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const hydrateV2Message = (sessionID: string, messageID: string) => {
|
||||
if (!sessionApi) return
|
||||
void sessionApi
|
||||
.message({ sessionID, messageID })
|
||||
.then((message) => {
|
||||
const current = data.session_message[sessionID] ?? []
|
||||
const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
|
||||
projectV2({ sessionID, messages, touched: [message.id] })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const applyV2 = (event: OpenCodeEvent) => {
|
||||
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
|
||||
const sessionID = event.data.sessionID
|
||||
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
|
||||
if (reduction) {
|
||||
projectV2(reduction)
|
||||
if (reduction.missing) hydrateV2Message(sessionID, reduction.missing)
|
||||
}
|
||||
|
||||
const info = data.info[sessionID]
|
||||
if (event.type === "session.renamed" && info)
|
||||
remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } })
|
||||
if (event.type === "session.moved" && info)
|
||||
remember({
|
||||
...info,
|
||||
projectID: event.data.projectID ?? info.projectID,
|
||||
workspaceID: event.data.location.workspaceID,
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
time: { ...info.time, updated: event.created },
|
||||
})
|
||||
if (event.type === "session.usage.updated" && info)
|
||||
remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
|
||||
if (event.type === "session.archived") {
|
||||
if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
|
||||
evict([sessionID])
|
||||
}
|
||||
if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
|
||||
if (
|
||||
event.type === "session.execution.succeeded" ||
|
||||
event.type === "session.execution.failed" ||
|
||||
event.type === "session.execution.interrupted"
|
||||
)
|
||||
setData("session_status", sessionID, { type: "idle" })
|
||||
if (event.type === "session.retry.scheduled")
|
||||
setData("session_status", sessionID, {
|
||||
type: "retry",
|
||||
attempt: event.data.attempt,
|
||||
message: event.data.error.message,
|
||||
next: event.data.at,
|
||||
})
|
||||
if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {})
|
||||
if (
|
||||
event.type === "session.revert.staged" ||
|
||||
event.type === "session.revert.cleared" ||
|
||||
event.type === "session.revert.committed"
|
||||
)
|
||||
void resolve(sessionID, { force: true }).catch(() => {})
|
||||
}
|
||||
|
||||
const apply = (event: { type: string; properties?: unknown }) => {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
@@ -984,9 +770,7 @@ export function createServerSession(
|
||||
return
|
||||
}
|
||||
case "session.deleted": {
|
||||
const properties = event.properties as { sessionID?: string; info?: Session }
|
||||
const sessionID = properties.info?.id ?? properties.sessionID
|
||||
if (!sessionID) return
|
||||
const sessionID = (event.properties as { info: Session }).info.id
|
||||
infoSeen.delete(sessionID)
|
||||
setData(
|
||||
"info",
|
||||
@@ -995,6 +779,11 @@ export function createServerSession(
|
||||
evict([sessionID])
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
case "todo.updated": {
|
||||
const props = event.properties as { sessionID: string; todos: Todo[] }
|
||||
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
|
||||
@@ -1007,7 +796,6 @@ export function createServerSession(
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
indexLegacyMessage(info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
@@ -1040,9 +828,6 @@ export function createServerSession(
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
setData("session_message", props.sessionID, (messages) =>
|
||||
messages?.filter((message) => message.id !== props.messageID),
|
||||
)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
load?.touchedMessages.add(props.messageID)
|
||||
load?.removedMessages.add(props.messageID)
|
||||
@@ -1355,16 +1140,23 @@ export function createServerSession(
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
async todo(sessionID: string, request?: { force?: boolean }) {
|
||||
diff(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||
if ((await options?.protocol) === "v2") {
|
||||
setData("todo", sessionID, [])
|
||||
return
|
||||
}
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
})
|
||||
})
|
||||
},
|
||||
todo(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const active = generation(sessionID)
|
||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
@@ -1398,7 +1190,6 @@ export function createServerSession(
|
||||
if (count && count > 1) pinned.set(sessionID, count - 1)
|
||||
},
|
||||
apply,
|
||||
applyV2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
McpApi,
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
describe("MCP queries", () => {
|
||||
test("loads current servers for the requested location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpQuery(ServerScope.local, "/project", {
|
||||
list: async (input: McpListInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: [
|
||||
{ name: "docs", status: { status: "connected" } },
|
||||
{ name: "search", status: { status: "pending" } },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
||||
})
|
||||
|
||||
test("loads and keys the current resource catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
||||
resource: {
|
||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: {
|
||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
||||
templates: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("active session query", () => {
|
||||
test("loads active sessions once per server cache", async () => {
|
||||
let calls = 0
|
||||
const queryClient = new QueryClient()
|
||||
const options = loadActiveSessionsQuery(ServerScope.local, {
|
||||
active: async () => {
|
||||
calls++
|
||||
return { ses_running: { type: "running" } }
|
||||
},
|
||||
})
|
||||
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } })
|
||||
expect(calls).toBe(1)
|
||||
expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"])
|
||||
})
|
||||
|
||||
test("does not overwrite statuses already written by events", () => {
|
||||
const session = createServerSession({} as OpencodeClient)
|
||||
session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 })
|
||||
|
||||
seedActiveSessionStatuses(session, {
|
||||
ses_running: { type: "running" },
|
||||
ses_retry: { type: "running" },
|
||||
})
|
||||
|
||||
expect(session.data.session_status.ses_running).toEqual({ type: "busy" })
|
||||
expect(session.data.session_status.ses_retry).toEqual({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "retrying",
|
||||
next: 10,
|
||||
})
|
||||
})
|
||||
})
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
@@ -120,57 +23,46 @@ describe("pickDirectoriesToEvict", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRootSessions", () => {
|
||||
test("loads and normalizes a limited page of root sessions", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
describe("loadRootSessionsWithFallback", () => {
|
||||
test("uses limited roots query when supported", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
|
||||
const result = await loadRootSessions({
|
||||
api: {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return { data: [sessionInfo("session-1")], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 10,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
return { data: [] }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([
|
||||
expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }),
|
||||
])
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }])
|
||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||
})
|
||||
|
||||
test("propagates list failures", () => {
|
||||
expect(
|
||||
loadRootSessions({
|
||||
api: {
|
||||
list: async () => {
|
||||
throw new Error("failed")
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">,
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
}),
|
||||
).rejects.toThrow("failed")
|
||||
test("falls back to full roots query on limited-query failure", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
if (query.limit) throw new Error("unsupported")
|
||||
return { data: [] }
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "dir", roots: true, limit: 25 },
|
||||
{ directory: "dir", roots: true },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: id,
|
||||
location: { directory: "dir" },
|
||||
} as SessionInfo
|
||||
}
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
Config,
|
||||
McpResource,
|
||||
OpencodeClient,
|
||||
Path,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
bootstrapGlobal,
|
||||
clearProviderRev,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
@@ -27,13 +26,12 @@ import {
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } 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"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import type { SolidQueryOptions } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { PathKey } from "@/utils/path-key"
|
||||
@@ -47,18 +45,8 @@ import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResource,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
McpServer,
|
||||
SessionActiveOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { createServerSession } from "./server-session"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -71,76 +59,16 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
|
||||
type McpResourceApi = {
|
||||
readonly resource: {
|
||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||
}
|
||||
}
|
||||
|
||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||
initialData?: undefined
|
||||
queryKey: K
|
||||
}
|
||||
|
||||
type SessionActiveApi = {
|
||||
readonly active: () => Promise<SessionActiveOutput>
|
||||
}
|
||||
|
||||
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"]>,
|
||||
Error,
|
||||
Record<string, McpServer["status"]>,
|
||||
readonly [ServerScope, string, "mcp"]
|
||||
>({
|
||||
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions({
|
||||
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])))
|
||||
},
|
||||
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
|
||||
})
|
||||
|
||||
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>,
|
||||
Error,
|
||||
Record<string, McpResource>,
|
||||
readonly [ServerScope, string, "mcpResources"]
|
||||
>({
|
||||
export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
||||
queryOptions<Record<string, McpResource>>({
|
||||
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) =>
|
||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||
)
|
||||
},
|
||||
queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}),
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
@@ -150,51 +78,22 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod
|
||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
||||
})
|
||||
|
||||
export const loadActiveSessionsQuery = (
|
||||
scope: ServerScope,
|
||||
api: SessionActiveApi,
|
||||
): ApiQueryOptions<SessionActiveOutput, readonly [ServerScope, "activeSessions"]> =>
|
||||
queryOptions<SessionActiveOutput, Error, SessionActiveOutput, readonly [ServerScope, "activeSessions"]>({
|
||||
queryKey: [scope, "activeSessions"] as const,
|
||||
queryFn: () => api.active(),
|
||||
enabled: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
export function seedActiveSessionStatuses(
|
||||
session: Pick<ServerSession, "data" | "set">,
|
||||
active: SessionActiveOutput | Record<string, SessionStatus>,
|
||||
) {
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
if (session.data.session_status[sessionID] !== undefined) continue
|
||||
const status = active[sessionID]
|
||||
session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status)
|
||||
}
|
||||
}
|
||||
|
||||
function makeQueryOptionsApi(
|
||||
scope: ServerScope,
|
||||
serverSDK: () => OpencodeClient,
|
||||
serverAPI: ServerApi,
|
||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
||||
protocol: Promise<"v1" | "v2">,
|
||||
) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||
projects: () => loadProjectsQuery(scope, serverSDK()),
|
||||
providers: (directory: PathKey | null) =>
|
||||
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
|
||||
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),
|
||||
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
path: (directory: PathKey | null) =>
|
||||
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
@@ -223,44 +122,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return sdk
|
||||
}
|
||||
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
() => serverSDK.client,
|
||||
serverSDK.api,
|
||||
sdkFor,
|
||||
serverSDK.protocol,
|
||||
)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
session.set("session_status", sessionID, reconcile(status))
|
||||
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)
|
||||
}
|
||||
return active
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
get ready() {
|
||||
@@ -317,8 +183,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryFn: async () => {
|
||||
await bootstrapGlobal({
|
||||
serverSDK: serverSDK.client,
|
||||
serverAPI: serverSDK.api,
|
||||
protocol: serverSDK.protocol,
|
||||
scope: serverSDK.scope,
|
||||
requestFailedTitle: language.t("common.requestFailed"),
|
||||
translate: language.t,
|
||||
@@ -348,6 +212,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
bootstrapInstance,
|
||||
})
|
||||
|
||||
const session = createServerSession(serverSDK.client)
|
||||
|
||||
const children = createChildStoreManager({
|
||||
owner,
|
||||
scope: serverSDK.scope,
|
||||
@@ -358,15 +224,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
void retry(() =>
|
||||
sdkFor(directory)
|
||||
.command.list()
|
||||
.then((x) => setStore("command", x.data ?? [])),
|
||||
).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
@@ -411,12 +279,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
.fetchQuery({
|
||||
...queryOptionsApi.sessions(key),
|
||||
queryFn: () =>
|
||||
serverSDK.protocol
|
||||
.then((protocol) =>
|
||||
protocol === "v1"
|
||||
? loadRootSessionsV1({ client: sdkFor(directory), directory, limit })
|
||||
: loadRootSessions({ api: serverSDK.api.session, directory, limit }),
|
||||
)
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => serverSDK.client.session.list(query),
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
@@ -486,7 +353,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
sdk,
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
setStore: child[1],
|
||||
vcsCache: cache,
|
||||
@@ -494,7 +360,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
translate: language.t,
|
||||
queryClient,
|
||||
session,
|
||||
protocol: serverSDK.protocol,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -506,31 +371,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const indexSession = (info: Parameters<typeof session.remember>[0]) => {
|
||||
const key = directoryKey(info.directory)
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info } },
|
||||
directory: key,
|
||||
store: existing[0],
|
||||
setStore: existing[1],
|
||||
push: queue.push,
|
||||
retainedLimit: sessionMeta.get(key)?.limit,
|
||||
sessionContent: false,
|
||||
permission: session.data.permission,
|
||||
loadLsp() {},
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const eventType: string = event.type
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (event.current) session.applyV2(event.current)
|
||||
session.apply(event)
|
||||
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
|
||||
homeSessions.apply(event)
|
||||
@@ -538,8 +384,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
homeSessions.refresh(event.type)
|
||||
|
||||
if (directory === "global") {
|
||||
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
|
||||
void activeSessionsQuery.refetch()
|
||||
applyGlobalEvent({
|
||||
event,
|
||||
project: globalStore.project,
|
||||
@@ -549,14 +393,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (
|
||||
eventType === "config.updated" ||
|
||||
eventType === "catalog.updated" ||
|
||||
eventType === "agent.updated" ||
|
||||
eventType === "project.directories.updated"
|
||||
)
|
||||
bootstrap.refetch()
|
||||
if (eventType === "server.connected" || eventType === "global.disposed") {
|
||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||
if (recent) return
|
||||
for (const directory of Object.keys(children.children)) {
|
||||
if (!children.active(directory)) continue
|
||||
@@ -566,30 +403,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.current?.type === "session.moved") {
|
||||
const info = session.get(event.current.data.sessionID)
|
||||
if (info) indexSession(info)
|
||||
}
|
||||
if (event.current?.type === "session.forked")
|
||||
void session
|
||||
.resolve(event.current.data.sessionID, { force: true })
|
||||
.then(indexSession)
|
||||
.catch(() => {})
|
||||
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
children.mark(key)
|
||||
if (
|
||||
event.current?.type === "session.moved" ||
|
||||
event.current?.type === "session.archived" ||
|
||||
event.current?.type === "session.forked" ||
|
||||
eventType === "command.updated" ||
|
||||
eventType === "config.updated" ||
|
||||
eventType === "agent.updated"
|
||||
)
|
||||
queue.push(key)
|
||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||
const [store, setStore] = existing
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
@@ -686,23 +502,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
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
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.connect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.connect({ server: name, location: { directory: key } })
|
||||
await sdk.mcp.connect({ name })
|
||||
},
|
||||
disconnect: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } })
|
||||
await sdk.mcp.disconnect({ name })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
|
||||
@@ -532,6 +532,7 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : []))
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
@@ -689,8 +690,8 @@ export default function Page() {
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||
.then((result) => result.data)
|
||||
.client.vcs.diff({ mode })
|
||||
.then((result) => list(result.data))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
return []
|
||||
@@ -737,12 +738,8 @@ export default function Page() {
|
||||
retry: 2,
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({
|
||||
location: { directory: scope },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
context,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
.client.vcs.diff({ mode, directory: scope, context })
|
||||
.then((result) => result.data ?? []),
|
||||
})
|
||||
.then((diffs) => diffs.find((diff) => diff.file === file))
|
||||
|
||||
@@ -949,11 +946,10 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
const details = evt.details as { type: string; properties?: unknown }
|
||||
if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof details.properties === "object" && details.properties
|
||||
? (details.properties as Record<string, unknown>)
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
@@ -1468,6 +1464,44 @@ export default function Page() {
|
||||
requestAnimationFrame(() => attempt(0))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
if (!wantsReview()) return
|
||||
if (sync().data.session_diff[id] !== undefined) return
|
||||
if (sync().status === "loading") return
|
||||
|
||||
void sync().session.diff(id)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
diffFrame = undefined
|
||||
diffTimer = undefined
|
||||
if (!wants) return
|
||||
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
if (!untrack(() => sync().data.session_diff[id] !== undefined)) return
|
||||
|
||||
diffFrame = requestAnimationFrame(() => {
|
||||
diffFrame = undefined
|
||||
diffTimer = window.setTimeout(() => {
|
||||
diffTimer = undefined
|
||||
if (sessionKey() !== key) return
|
||||
void sync().session.diff(id, { force: true })
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk().directory
|
||||
@@ -1723,7 +1757,7 @@ export default function Page() {
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
client: sdk().client,
|
||||
sync: sync(),
|
||||
serverSync: serverSync(),
|
||||
draft: item,
|
||||
@@ -1819,13 +1853,13 @@ export default function Page() {
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
.client.session.abort({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const session = sdk().api.session
|
||||
const client = sdk().client
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
@@ -1835,8 +1869,10 @@ export default function Page() {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
|
||||
complete: () => undefined,
|
||||
request: () => halt(input.sessionID).then(() => client.session.revert(input)),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
@@ -1848,7 +1884,7 @@ export default function Page() {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const session = sdk().api.session
|
||||
const client = sdk().client
|
||||
const target = sync()
|
||||
const next = userMessages().find((item) => item.id > id)
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
@@ -1865,9 +1901,11 @@ export default function Page() {
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => session.revert.clear({ sessionID }))
|
||||
: halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
|
||||
complete: () => undefined,
|
||||
? halt(sessionID).then(() => client.session.unrevert({ sessionID }))
|
||||
: halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })),
|
||||
complete: (result) => {
|
||||
if (result.data) merge(result.data, target)
|
||||
},
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
||||
) {
|
||||
const requests: Request[] = []
|
||||
const fetcher = Object.assign(
|
||||
async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "PATCH") {
|
||||
return Response.json({
|
||||
id: "ses_1",
|
||||
slug: "ses_1",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
|
||||
return new Response(undefined, { status: 204 })
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
||||
return Response.json({
|
||||
admittedSeq: 1,
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
data: { text: "hello" },
|
||||
delivery: "steer",
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
||||
return Response.json(responses?.vcs ?? {})
|
||||
if (request.method === "GET") return Response.json([])
|
||||
return new Response(undefined, { status: 204 })
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
)
|
||||
const server = { url: "http://localhost:4096" }
|
||||
const api = createCompatibleApi({
|
||||
protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol,
|
||||
current: createApiForServer({ server, fetch: fetcher }),
|
||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||
directory: "/repo",
|
||||
})
|
||||
return { api, requests }
|
||||
}
|
||||
|
||||
describe("createCompatibleApi", () => {
|
||||
test("routes V1 archive through the legacy session update", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
|
||||
|
||||
const url = new URL(requests[0]!.url)
|
||||
expect(url.pathname).toBe("/session/ses_1")
|
||||
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
||||
expect(requests[0]!.method).toBe("PATCH")
|
||||
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
|
||||
})
|
||||
|
||||
test("converts current prompts to the V1 prompt contract", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.prompt({
|
||||
sessionID: "ses_1",
|
||||
id: "msg_1",
|
||||
text: "hello",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
})
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||
expect(await requests[0]!.json()).toMatchObject({
|
||||
messageID: "msg_1",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps V2 session actions on the current API", async () => {
|
||||
const { api, requests } = setup("v2")
|
||||
await api.session.archive({ sessionID: "ses_1" })
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
|
||||
expect(requests[0]!.method).toBe("POST")
|
||||
})
|
||||
|
||||
test("resolves protocol detection once across implementation methods", async () => {
|
||||
let detections = 0
|
||||
const resolved = Promise.resolve<"v1" | "v2">("v2")
|
||||
const protocol = new Proxy(resolved, {
|
||||
get(target, property) {
|
||||
if (property !== "then") return Reflect.get(target, property, target)
|
||||
detections++
|
||||
return target.then.bind(target)
|
||||
},
|
||||
})
|
||||
const { api } = setup(protocol)
|
||||
|
||||
await api.session.archive({ sessionID: "ses_1" })
|
||||
await api.session.list()
|
||||
|
||||
expect(detections).toBe(1)
|
||||
})
|
||||
|
||||
test("uses the global V1 session search endpoint", async () => {
|
||||
const { api, requests } = setup("v1")
|
||||
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
||||
|
||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||
})
|
||||
|
||||
test("projects the V1 default branch", async () => {
|
||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
||||
|
||||
expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({
|
||||
data: { branch: "feature", defaultBranch: "dev" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,495 +0,0 @@
|
||||
import type { ServerApi } from "./server"
|
||||
import type { ServerProtocol } from "./server-protocol"
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
Project,
|
||||
ProjectCurrent,
|
||||
SessionApi,
|
||||
SessionCommandInput,
|
||||
SessionCommandOutput,
|
||||
SessionCompactInput,
|
||||
SessionCompactOutput,
|
||||
SessionInfo,
|
||||
SessionPromptInput,
|
||||
SessionPromptOutput,
|
||||
SessionShellInput,
|
||||
SessionShellOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
type LegacyClient = OpencodeClient
|
||||
type LegacyFor = (directory?: string) => LegacyClient
|
||||
type CompatibleSessionApi = Omit<
|
||||
SessionApi,
|
||||
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
|
||||
> & {
|
||||
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
|
||||
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
|
||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||
}
|
||||
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
|
||||
type LegacyPrompt = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
type LegacyLocation = { directory?: string }
|
||||
type CompatibleInput = {
|
||||
protocol: Promise<ServerProtocol>
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
}
|
||||
|
||||
function mime(uri: string) {
|
||||
const match = /^data:([^;,]+)/.exec(uri)
|
||||
return match?.[1] ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
function sessionInfo(session: Session): SessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID,
|
||||
agent: session.agent,
|
||||
model: session.model && {
|
||||
id: session.model.id,
|
||||
providerID: session.model.providerID,
|
||||
variant: session.model.variant,
|
||||
},
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: session.time,
|
||||
title: session.title,
|
||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||
subpath: session.path,
|
||||
revert: session.revert && {
|
||||
messageID: session.revert.messageID,
|
||||
partID: session.revert.partID,
|
||||
snapshot: session.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
||||
const v1 = createV1Api(input)
|
||||
return lazyApi(
|
||||
input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)),
|
||||
input.current,
|
||||
)
|
||||
}
|
||||
|
||||
function lazyApi<T extends object>(implementation: Promise<T>, shape: T): T {
|
||||
const cache = new Map<PropertyKey, unknown>()
|
||||
return new Proxy(shape, {
|
||||
get(target, property, receiver) {
|
||||
const sample = Reflect.get(target, property, receiver)
|
||||
if (typeof sample === "function") {
|
||||
return (...args: unknown[]) =>
|
||||
implementation.then((value) => {
|
||||
const method = Reflect.get(value, property)
|
||||
if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`)
|
||||
return Reflect.apply(method, value, args)
|
||||
})
|
||||
}
|
||||
if (sample === null || typeof sample !== "object") return sample
|
||||
if (cache.has(property)) return cache.get(property)
|
||||
const nested = lazyApi(
|
||||
implementation.then((value) => {
|
||||
const result = Reflect.get(value, property)
|
||||
if (result === null || typeof result !== "object") {
|
||||
throw new Error(`API namespace unavailable: ${String(property)}`)
|
||||
}
|
||||
return result
|
||||
}),
|
||||
sample,
|
||||
)
|
||||
cache.set(property, nested)
|
||||
return nested
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
|
||||
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
||||
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||
location: {
|
||||
directory: directory(value) ?? "",
|
||||
project: { id: "", directory: directory(value) ?? "" },
|
||||
},
|
||||
data,
|
||||
})
|
||||
|
||||
return {
|
||||
...input.current,
|
||||
session: {
|
||||
...input.current.session,
|
||||
async list(
|
||||
value?: Parameters<ServerApi["session"]["list"]>[0],
|
||||
options?: Parameters<ServerApi["session"]["list"]>[1],
|
||||
) {
|
||||
if (!value?.directory && value?.search !== undefined) {
|
||||
const result = await legacy().experimental.session.list(
|
||||
{
|
||||
roots: value.parentID === null ? true : undefined,
|
||||
search: value.search,
|
||||
limit: value.limit,
|
||||
},
|
||||
options,
|
||||
)
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
}
|
||||
const result = await legacy({ directory: value?.directory }).session.list({
|
||||
directory: value?.directory,
|
||||
roots: value?.parentID === null ? true : undefined,
|
||||
search: value?.search,
|
||||
limit: value?.limit,
|
||||
})
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
||||
const result = await legacy(value?.location ?? undefined).session.create({
|
||||
directory: directory(value?.location ?? undefined),
|
||||
})
|
||||
if (!result.data) throw new Error("Failed to create session")
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||
const result = await legacy().session.get(value)
|
||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async active() {
|
||||
const result = await legacy().session.status()
|
||||
return Object.fromEntries(
|
||||
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
},
|
||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||
},
|
||||
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||
await legacy(value).session.delete(value)
|
||||
},
|
||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||
const result = await legacy().session.fork(value)
|
||||
if (!result.data) throw new Error("Failed to fork session")
|
||||
return sessionInfo(result.data)
|
||||
},
|
||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||
await legacy().session.abort(value)
|
||||
},
|
||||
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
||||
await legacy().session.promptAsync({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
agent: value.agent,
|
||||
model: value.model,
|
||||
variant: value.variant,
|
||||
parts: [
|
||||
{ type: "text", text: value.text },
|
||||
...(value.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
mime: mime(file.uri),
|
||||
url: file.uri,
|
||||
filename: file.name,
|
||||
})),
|
||||
...(value.agents ?? []).map((agent) => ({
|
||||
type: "agent" as const,
|
||||
name: agent.name,
|
||||
source: agent.mention
|
||||
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
|
||||
: undefined,
|
||||
})),
|
||||
],
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
data: { text: value.text },
|
||||
delivery: value.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
async command(value: SessionCommandInput) {
|
||||
await legacy().session.command({
|
||||
sessionID: value.sessionID,
|
||||
messageID: value.id ?? undefined,
|
||||
command: value.command,
|
||||
arguments: value.arguments ?? "",
|
||||
agent: value.agent ?? undefined,
|
||||
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
|
||||
variant: value.model?.variant,
|
||||
parts: value.files?.map((file) => ({
|
||||
type: "file" as const,
|
||||
mime: mime(file.uri),
|
||||
url: file.uri,
|
||||
filename: file.name,
|
||||
})),
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "user",
|
||||
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
|
||||
delivery: value.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
async shell(value: SessionShellInput & LegacyPrompt) {
|
||||
await legacy().session.shell({
|
||||
sessionID: value.sessionID,
|
||||
command: value.command,
|
||||
agent: value.agent,
|
||||
model: value.model,
|
||||
})
|
||||
},
|
||||
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
|
||||
if (!value.model) throw new Error("A model is required to compact a V1 session")
|
||||
await legacy().session.summarize({
|
||||
sessionID: value.sessionID,
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "compaction",
|
||||
}
|
||||
},
|
||||
revert: {
|
||||
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
||||
await legacy().session.revert(value)
|
||||
return { messageID: value.messageID }
|
||||
},
|
||||
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
||||
await legacy().session.unrevert(value)
|
||||
},
|
||||
commit: input.current.session.revert.commit,
|
||||
},
|
||||
},
|
||||
project: {
|
||||
...input.current.project,
|
||||
async list() {
|
||||
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||
},
|
||||
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||
const result = await legacy(value?.location).project.current()
|
||||
if (!result.data) throw new Error("Project not found")
|
||||
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||
},
|
||||
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||
const result = await legacy({ directory: project?.worktree }).project.update({
|
||||
...value,
|
||||
directory: project?.worktree,
|
||||
})
|
||||
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
||||
return result.data as Project
|
||||
},
|
||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||
const result = await legacy(value.location).worktree.list()
|
||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||
},
|
||||
},
|
||||
path: {
|
||||
...input.current.path,
|
||||
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).path.get()
|
||||
if (!result.data) throw new Error("Path unavailable")
|
||||
return result.data
|
||||
},
|
||||
},
|
||||
vcs: {
|
||||
...input.current.vcs,
|
||||
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.get()
|
||||
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
||||
},
|
||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||
const result = await legacy(value?.location).vcs.status()
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
||||
const result = await legacy(value.location).vcs.diff({
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
})
|
||||
return located(
|
||||
(result.data ?? []).map((file) => ({
|
||||
file: file.file,
|
||||
patch: file.patch ?? "",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status ?? "modified",
|
||||
})),
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
file: {
|
||||
...input.current.file,
|
||||
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
|
||||
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
||||
const result = await legacy(value.location).find.files({
|
||||
query: value.query,
|
||||
type: value.type,
|
||||
limit: value.limit,
|
||||
})
|
||||
return located(
|
||||
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
...input.current.integration,
|
||||
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
|
||||
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
|
||||
(method, index) =>
|
||||
method.type === "api"
|
||||
? { type: "key" as const, label: method.label }
|
||||
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
|
||||
)
|
||||
return located(
|
||||
{
|
||||
id: value.integrationID,
|
||||
name: value.integrationID,
|
||||
methods,
|
||||
connections: [],
|
||||
},
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
connect: {
|
||||
...input.current.integration.connect,
|
||||
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
|
||||
await legacy(value.location).auth.set({
|
||||
providerID: value.integrationID,
|
||||
auth: { type: "api", key: value.key },
|
||||
})
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
...input.current.integration.oauth,
|
||||
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
|
||||
const method = Number(value.methodID)
|
||||
const result = await legacy(value.location).provider.oauth.authorize(
|
||||
{ providerID: value.integrationID, method, inputs: value.inputs },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
if (!result.data) throw new Error("Failed to start OAuth authorization")
|
||||
return located(
|
||||
{
|
||||
attemptID: `${value.integrationID}:${method}`,
|
||||
url: result.data.url,
|
||||
instructions: result.data.instructions,
|
||||
mode: result.data.method,
|
||||
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
|
||||
},
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
|
||||
const method = Number(value.attemptID.split(":").at(-1))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method, code: value.code },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
},
|
||||
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
|
||||
const method = Number(value.attemptID.split(":").at(-1))
|
||||
await legacy(value.location).provider.oauth.callback(
|
||||
{ providerID: value.integrationID, method },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
return located(
|
||||
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
|
||||
value.location,
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
pty: {
|
||||
...input.current.pty,
|
||||
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
||||
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
||||
},
|
||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
|
||||
const result = await legacy(value?.location).pty.create({
|
||||
command: value?.command,
|
||||
args: value?.args ? [...value.args] : undefined,
|
||||
cwd: value?.cwd,
|
||||
title: value?.title,
|
||||
env: value?.env,
|
||||
})
|
||||
if (!result.data) throw new Error("Failed to create terminal")
|
||||
return located(result.data, value?.location)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
|
||||
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
|
||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
|
||||
const result = await legacy(value.location).pty.update({
|
||||
ptyID: value.ptyID,
|
||||
title: value.title,
|
||||
size: value.size,
|
||||
})
|
||||
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||
},
|
||||
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
||||
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
||||
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
||||
return located(result.data, value.location)
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
...input.current.permission,
|
||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
||||
await legacy().permission.respond({
|
||||
sessionID: value.sessionID,
|
||||
permissionID: value.requestID,
|
||||
response: value.reply,
|
||||
})
|
||||
},
|
||||
},
|
||||
question: {
|
||||
...input.current.question,
|
||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||
await legacy().question.reply({
|
||||
requestID: value.requestID,
|
||||
answers: value.answers.map((answer) => [...answer]),
|
||||
})
|
||||
},
|
||||
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
||||
await legacy().question.reject({ requestID: value.requestID })
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -14,45 +14,15 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
||||
|
||||
describe("checkServerHealth", () => {
|
||||
test("returns healthy response with version", async () => {
|
||||
let request: URL | undefined
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
const fetch = (async () =>
|
||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth(server, fetch)
|
||||
|
||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||
expect(request?.pathname).toBe("/api/health")
|
||||
})
|
||||
|
||||
test("falls back to the V1 health endpoint", async () => {
|
||||
const paths: string[] = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
paths.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
|
||||
return Response.json({ healthy: true, version: "1.18.4" })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||
})
|
||||
|
||||
test("falls back when the current health response is malformed", async () => {
|
||||
const paths: string[] = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
paths.push(url.pathname)
|
||||
if (url.pathname === "/api/health") return Response.json({})
|
||||
return Response.json({ healthy: true, version: "1.18.4" })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||
})
|
||||
|
||||
test("allows slow servers thirty seconds by default", async () => {
|
||||
@@ -172,7 +142,7 @@ describe("checkServerHealth", () => {
|
||||
retryDelayMs: 1,
|
||||
})
|
||||
|
||||
expect(count).toBe(6)
|
||||
expect(count).toBe(3)
|
||||
expect(result).toEqual({ healthy: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
||||
import { ClientError, OpenCode } from "@opencode-ai/client"
|
||||
import { createSdkForServer } from "./server"
|
||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
@@ -62,7 +61,6 @@ function wait(ms: number, signal?: AbortSignal) {
|
||||
|
||||
function retryable(error: unknown, signal?: AbortSignal) {
|
||||
if (signal?.aborted) return false
|
||||
if (error instanceof ClientError) return error.reason === "Transport"
|
||||
if (!(error instanceof Error)) return false
|
||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||
if (error instanceof TypeError) return true
|
||||
@@ -84,31 +82,15 @@ export async function checkServerHealth(
|
||||
.then(() => attempt(count + 1))
|
||||
.catch(() => ({ healthy: false }))
|
||||
}
|
||||
const attempt = async (count: number): Promise<ServerHealth> => {
|
||||
const current = await OpenCode.make({
|
||||
baseUrl: server.url,
|
||||
const attempt = (count: number): Promise<ServerHealth> =>
|
||||
createSdkForServer({
|
||||
server,
|
||||
fetch,
|
||||
headers: server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
: undefined,
|
||||
signal,
|
||||
})
|
||||
.health.get({ signal })
|
||||
.then((x) =>
|
||||
typeof x.healthy === "boolean"
|
||||
? { data: { healthy: x.healthy, version: x.version } }
|
||||
: { error: new Error("Invalid health response") },
|
||||
)
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current && current.data) return current.data
|
||||
if (signal?.aborted) return { healthy: false }
|
||||
|
||||
return createSdkForServer({ server, fetch, signal })
|
||||
.global.health()
|
||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.catch((error) => next(count, error))
|
||||
}
|
||||
return attempt(0).finally(() => timeout?.clear?.())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { detectServerProtocol } from "./server-protocol"
|
||||
|
||||
const server = { url: "http://localhost:4096" }
|
||||
const json = (value: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
|
||||
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
|
||||
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
|
||||
|
||||
describe("detectServerProtocol", () => {
|
||||
test("prefers the legacy health endpoint when both API generations exist", async () => {
|
||||
const fetcher = mockFetch((input) => {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
|
||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
||||
})
|
||||
|
||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
||||
})
|
||||
|
||||
test("recognizes V2 health by its process identifier", async () => {
|
||||
const fetcher = mockFetch((input) => {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
||||
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
||||
})
|
||||
|
||||
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
|
||||
})
|
||||
|
||||
test("recognizes the transitional V1 API health response", async () => {
|
||||
const fetcher = mockFetch((input) => {
|
||||
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
||||
return Promise.resolve(json({ healthy: true }))
|
||||
})
|
||||
|
||||
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { authTokenFromCredentials } from "./server"
|
||||
|
||||
export type ServerProtocol = "v1" | "v2"
|
||||
|
||||
function headers(server: ServerConnection.HttpBase) {
|
||||
if (!server.password) return
|
||||
return {
|
||||
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
|
||||
const response = await fetch(new URL(path, server.url), {
|
||||
headers: headers(server),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
|
||||
const value: unknown = await response.json()
|
||||
if (!value || typeof value !== "object") return
|
||||
return value
|
||||
}
|
||||
|
||||
export async function detectServerProtocol(
|
||||
server: ServerConnection.HttpBase,
|
||||
fetch: typeof globalThis.fetch,
|
||||
): Promise<ServerProtocol> {
|
||||
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
|
||||
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
|
||||
|
||||
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
|
||||
if (current && "pid" in current && typeof current.pid === "number") return "v2"
|
||||
if (current && "healthy" in current && current.healthy === true) return "v1"
|
||||
return "v2"
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
@@ -40,23 +39,3 @@ export function createSdkForServer({
|
||||
baseUrl: server.url,
|
||||
})
|
||||
}
|
||||
|
||||
export function createApiForServer(input: {
|
||||
server: ServerConnection.HttpBase
|
||||
fetch?: typeof globalThis.fetch
|
||||
}): OpenCodeClient {
|
||||
return OpenCode.make({
|
||||
baseUrl: input.server.url,
|
||||
fetch: input.fetch,
|
||||
headers: input.server.password
|
||||
? {
|
||||
Authorization: `Basic ${authTokenFromCredentials({
|
||||
username: input.server.username,
|
||||
password: input.server.password,
|
||||
})}`,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export type ServerApi = OpenCodeClient
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { normalizeSessionMessages } from "./session-message"
|
||||
|
||||
describe("normalizeSessionMessages", () => {
|
||||
test("projects current turns into stable legacy rendering records", () => {
|
||||
const source = [
|
||||
{ id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "model-switched",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
{
|
||||
id: "msg_3",
|
||||
type: "user",
|
||||
text: "inspect this",
|
||||
files: [
|
||||
{
|
||||
data: "aGVsbG8=",
|
||||
mime: "text/plain",
|
||||
name: "note.txt",
|
||||
source: { type: "inline" },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }],
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_4",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude", providerID: "anthropic", variant: "high" },
|
||||
content: [
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } },
|
||||
{ type: "text", text: "Result" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "note.txt" },
|
||||
structured: { title: "note.txt" },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
},
|
||||
time: { created: 5, ran: 6, completed: 7 },
|
||||
},
|
||||
],
|
||||
cost: 0.1,
|
||||
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } },
|
||||
time: { created: 4, completed: 7 },
|
||||
},
|
||||
{
|
||||
id: "msg_5",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "summary",
|
||||
recent: "recent",
|
||||
time: { created: 8 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages[0]).toMatchObject({
|
||||
id: "msg_3",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude", variant: "high" },
|
||||
})
|
||||
expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 })
|
||||
expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([
|
||||
"msg_3:text:0",
|
||||
"msg_3:file:0",
|
||||
"msg_3:agent:0",
|
||||
"msg_5:compaction",
|
||||
])
|
||||
expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"])
|
||||
expect(result.parts.get("msg_4")?.[2]).toMatchObject({
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: { status: "completed", output: "hello" },
|
||||
})
|
||||
})
|
||||
|
||||
test("does not invent a parent for an assistant-only page", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_2",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [{ type: "text", text: "orphan" }],
|
||||
time: { created: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
expect(normalizeSessionMessages("ses_1", source).messages).toEqual([])
|
||||
})
|
||||
|
||||
test("projects a current shell message into a renderable standalone turn", () => {
|
||||
const source = [
|
||||
{
|
||||
id: "msg_shell",
|
||||
type: "shell",
|
||||
shellID: "shell_1",
|
||||
command: "printf hello",
|
||||
status: "exited",
|
||||
exit: 0,
|
||||
output: { output: "hello", cursor: 5, size: 5, truncated: false },
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({ id: "msg_shell", role: "user" }),
|
||||
expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }),
|
||||
])
|
||||
expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })])
|
||||
expect(result.parts.get("msg_shell:assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: { command: "printf hello" },
|
||||
output: "hello",
|
||||
title: "Shell",
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("adapts current edit fields for the legacy edit renderer", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "edit it", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
|
||||
content: [{ type: "text", text: "Edited file successfully" }],
|
||||
structured: {
|
||||
files: [
|
||||
{
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
},
|
||||
],
|
||||
replacements: 1,
|
||||
},
|
||||
},
|
||||
time: { created: 2, ran: 3, completed: 4 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 4 },
|
||||
},
|
||||
] satisfies SessionMessageInfo[]
|
||||
|
||||
const result = normalizeSessionMessages("ses_1", source)
|
||||
|
||||
expect(result.parts.get("msg_assistant")).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool",
|
||||
tool: "edit",
|
||||
state: expect.objectContaining({
|
||||
status: "completed",
|
||||
input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }),
|
||||
metadata: expect.objectContaining({
|
||||
filediff: {
|
||||
file: "README.md",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,348 +0,0 @@
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
SessionMessageShell,
|
||||
SessionMessageUser,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Option, Schema } from "effect"
|
||||
|
||||
const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" }
|
||||
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeToolInput(name: string, input: Record<string, unknown>) {
|
||||
if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string")
|
||||
return input
|
||||
return { ...input, filePath: input.path }
|
||||
}
|
||||
|
||||
function normalizeToolMetadata(name: string, metadata: Record<string, unknown>) {
|
||||
if (name !== "edit" || !Array.isArray(metadata.files)) return metadata
|
||||
const file = metadata.files.find(record)
|
||||
if (!file || typeof file.file !== "string") return metadata
|
||||
return {
|
||||
...metadata,
|
||||
filediff: {
|
||||
file: file.file,
|
||||
patch: typeof file.patch === "string" ? file.patch : undefined,
|
||||
additions: typeof file.additions === "number" ? file.additions : 0,
|
||||
deletions: typeof file.deletions === "number" ? file.deletions : 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) {
|
||||
const messages: Message[] = []
|
||||
const parts = new Map<string, Part[]>()
|
||||
let agent = ""
|
||||
let model = emptyModel
|
||||
let parentID: string | undefined
|
||||
|
||||
source.forEach((message) => {
|
||||
if (message.type === "agent-switched") {
|
||||
agent = message.agent
|
||||
return
|
||||
}
|
||||
if (message.type === "model-switched") {
|
||||
model = message.model
|
||||
return
|
||||
}
|
||||
if (message.type === "user") {
|
||||
parentID = message.id
|
||||
messages.push(userMessage(sessionID, message, agent, model))
|
||||
parts.set(message.id, userParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type === "synthetic" && message.description?.trim()) {
|
||||
parentID = message.id
|
||||
messages.push({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
})
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)])
|
||||
return
|
||||
}
|
||||
if (message.type === "shell") {
|
||||
messages.push(...shellMessages(sessionID, message, agent, model))
|
||||
parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)])
|
||||
parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)])
|
||||
parentID = undefined
|
||||
return
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
agent = message.agent
|
||||
model = message.model
|
||||
if (!parentID) return
|
||||
const parent = messages.findLast((item) => item.id === parentID)
|
||||
if (parent?.role === "user") {
|
||||
parent.agent = message.agent
|
||||
parent.model = {
|
||||
providerID: message.model.providerID,
|
||||
modelID: message.model.id,
|
||||
variant: message.model.variant,
|
||||
}
|
||||
}
|
||||
messages.push(assistantMessage(sessionID, parentID, message))
|
||||
parts.set(message.id, assistantParts(sessionID, message))
|
||||
return
|
||||
}
|
||||
if (message.type !== "compaction" || !parentID) return
|
||||
parts.set(parentID, [
|
||||
...(parts.get(parentID) ?? []),
|
||||
{
|
||||
id: `${message.id}:compaction`,
|
||||
sessionID,
|
||||
messageID: parentID,
|
||||
type: "compaction",
|
||||
auto: message.reason === "auto",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
return { messages, parts }
|
||||
}
|
||||
|
||||
function shellMessages(
|
||||
sessionID: string,
|
||||
message: SessionMessageShell,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): [UserMessage, AssistantMessage] {
|
||||
return [
|
||||
{
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: message.time.created },
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
},
|
||||
{
|
||||
id: `${message.id}:assistant`,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
parentID: message.id,
|
||||
modelID: model.id,
|
||||
providerID: model.providerID,
|
||||
variant: model.variant,
|
||||
mode: agent,
|
||||
agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: 0,
|
||||
tokens: emptyTokens,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function shellPart(sessionID: string, message: SessionMessageShell): ToolPart {
|
||||
const input = { command: message.command }
|
||||
const start = message.time.created
|
||||
const state: ToolPart["state"] =
|
||||
message.status === "running"
|
||||
? { status: "running", input, time: { start } }
|
||||
: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: message.output?.output ?? "",
|
||||
title: "Shell",
|
||||
metadata: {
|
||||
status: message.status,
|
||||
exit: message.exit,
|
||||
truncated: message.output?.truncated,
|
||||
},
|
||||
time: { start, end: message.time.completed ?? start },
|
||||
}
|
||||
return {
|
||||
id: `${message.id}:tool`,
|
||||
sessionID,
|
||||
messageID: `${message.id}:assistant`,
|
||||
type: "tool",
|
||||
callID: message.shellID,
|
||||
tool: "bash",
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) {
|
||||
return `${messageID}:${type}:${ordinal}`
|
||||
}
|
||||
|
||||
function userMessage(
|
||||
sessionID: string,
|
||||
message: SessionMessageUser,
|
||||
agent: string,
|
||||
model: { id: string; providerID: string; variant?: string },
|
||||
): UserMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: message.time,
|
||||
agent,
|
||||
model: { providerID: model.providerID, modelID: model.id, variant: model.variant },
|
||||
}
|
||||
}
|
||||
|
||||
function userParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
return [
|
||||
textPart(sessionID, message.id, 0, message.text),
|
||||
...(message.files ?? []).map(
|
||||
(file, index): FilePart => ({
|
||||
id: `${message.id}:file:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "file",
|
||||
mime: file.mime,
|
||||
filename: file.name,
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
}),
|
||||
),
|
||||
...(message.agents ?? []).map(
|
||||
(item, index): Part => ({
|
||||
id: `${message.id}:agent:${index}`,
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "agent",
|
||||
name: item.name,
|
||||
source: item.mention
|
||||
? { value: item.mention.text, start: item.mention.start, end: item.mention.end }
|
||||
: undefined,
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage {
|
||||
const error = message.error
|
||||
? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt")
|
||||
? { name: "MessageAbortedError" as const, data: { message: message.error.message } }
|
||||
: { name: "UnknownError" as const, data: { message: message.error.message } }
|
||||
: undefined
|
||||
return {
|
||||
id: message.id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: message.time,
|
||||
error,
|
||||
parentID,
|
||||
modelID: message.model.id,
|
||||
providerID: message.model.providerID,
|
||||
variant: message.model.variant,
|
||||
mode: message.agent,
|
||||
agent: message.agent,
|
||||
path: { cwd: "", root: "" },
|
||||
cost: message.cost ?? 0,
|
||||
tokens: message.tokens ?? emptyTokens,
|
||||
finish: message.finish,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.flatMap((content): Part[] => {
|
||||
if (content.type === "text") {
|
||||
const part = textPart(sessionID, message.id, ordinals.text++, content.text)
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
if (content.type === "reasoning") {
|
||||
const part: Part = {
|
||||
id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++),
|
||||
sessionID,
|
||||
messageID: message.id,
|
||||
type: "reasoning",
|
||||
text: content.text,
|
||||
metadata: content.state,
|
||||
time: {
|
||||
start: content.time?.created ?? message.time.created,
|
||||
end: content.time?.completed,
|
||||
},
|
||||
}
|
||||
return content.text.trim() ? [part] : []
|
||||
}
|
||||
return [toolPart(sessionID, message.id, content)]
|
||||
})
|
||||
}
|
||||
|
||||
function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part {
|
||||
return {
|
||||
id: sessionMessagePartID(messageID, "text", ordinal),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic,
|
||||
}
|
||||
}
|
||||
|
||||
function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart {
|
||||
const start = tool.time.ran ?? tool.time.created
|
||||
const state = (() => {
|
||||
if (tool.state.status === "streaming") {
|
||||
const value = Option.getOrUndefined(decodeToolInput(tool.state.input))
|
||||
const input = normalizeToolInput(tool.name, record(value) ? value : {})
|
||||
return { status: "pending" as const, input, raw: tool.state.input }
|
||||
}
|
||||
if (tool.state.status === "running") {
|
||||
return {
|
||||
status: "running" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start },
|
||||
}
|
||||
}
|
||||
if (tool.state.status === "error") {
|
||||
return {
|
||||
status: "error" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
error: tool.state.error.message,
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
}
|
||||
}
|
||||
const attachments = tool.state.content.flatMap((item, index): FilePart[] =>
|
||||
item.type === "file"
|
||||
? [
|
||||
{
|
||||
id: `${tool.id}:file:${index}`,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "file",
|
||||
mime: item.mime,
|
||||
filename: item.name,
|
||||
url: item.uri,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
return {
|
||||
status: "completed" as const,
|
||||
input: normalizeToolInput(tool.name, tool.state.input),
|
||||
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
|
||||
title: tool.name,
|
||||
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
|
||||
time: { start, end: tool.time.completed ?? start },
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
})()
|
||||
return {
|
||||
id: tool.id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "tool",
|
||||
callID: tool.id,
|
||||
tool: tool.name,
|
||||
state,
|
||||
metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState },
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import { listAllSessions, normalizeSessionInfo } from "./session"
|
||||
|
||||
describe("normalizeSessionInfo", () => {
|
||||
test("adapts a current session to the app session shape", () => {
|
||||
const result = normalizeSessionInfo({
|
||||
id: "session-1",
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
title: "New session",
|
||||
location: { directory: "/repo/worktree", workspaceID: "workspace-1" },
|
||||
subpath: "worktree",
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] },
|
||||
} as SessionInfo)
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "session-1",
|
||||
slug: "session-1",
|
||||
projectID: "project-1",
|
||||
workspaceID: "workspace-1",
|
||||
directory: "/repo/worktree",
|
||||
path: "worktree",
|
||||
parentID: undefined,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
title: "New session",
|
||||
agent: "build",
|
||||
model: { id: "gpt-5", providerID: "openai", variant: "high" },
|
||||
version: "",
|
||||
time: { created: 1, updated: 1 },
|
||||
revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("listAllSessions", () => {
|
||||
test("loads every page in server order and retains the query", async () => {
|
||||
const calls: SessionListInput[] = []
|
||||
const pages = new Map<string | undefined, { data: SessionInfo[]; cursor: { next?: string } }>([
|
||||
[undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }],
|
||||
["next", { data: [sessionInfo("session-1", true)], cursor: {} }],
|
||||
])
|
||||
const api = {
|
||||
list: async (query = {}) => {
|
||||
calls.push(query)
|
||||
return pages.get(query.cursor) ?? { data: [], cursor: {} }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", order: "desc" })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"])
|
||||
expect(result[2]?.time.archived).toBe(2)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: undefined },
|
||||
{ directory: "/repo", order: "desc", limit: 100, cursor: "next" },
|
||||
])
|
||||
})
|
||||
|
||||
test("requests the terminal empty page when the server returns a next cursor", async () => {
|
||||
const cursors: Array<string | undefined> = []
|
||||
const api = {
|
||||
list: async (query = {}) => {
|
||||
cursors.push(query.cursor)
|
||||
if (query.cursor) return { data: [], cursor: { next: "unused" } }
|
||||
return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } }
|
||||
},
|
||||
} satisfies Pick<SessionApi, "list">
|
||||
|
||||
const result = await listAllSessions(api, { directory: "/repo", limit: 25 })
|
||||
|
||||
expect(result.map((session) => session.id)).toEqual(["session-1"])
|
||||
expect(cursors).toEqual([undefined, "terminal"])
|
||||
})
|
||||
})
|
||||
|
||||
function sessionInfo(id: string, archived = false) {
|
||||
return {
|
||||
id,
|
||||
projectID: "project-1",
|
||||
agent: "build",
|
||||
model: { id: "model-1", providerID: "provider-1" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1, archived: archived ? 2 : undefined },
|
||||
title: id,
|
||||
location: { directory: "/repo" },
|
||||
} as SessionInfo
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
|
||||
if (!("location" in input)) return input
|
||||
return {
|
||||
id: input.id,
|
||||
slug: input.id,
|
||||
projectID: input.projectID,
|
||||
workspaceID: input.location.workspaceID,
|
||||
directory: input.location.directory,
|
||||
path: input.subpath,
|
||||
parentID: input.parentID,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
version: "",
|
||||
time: input.time,
|
||||
revert: input.revert && {
|
||||
messageID: input.revert.messageID,
|
||||
partID: input.revert.partID,
|
||||
snapshot: input.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAllSessions(api: Pick<SessionApi, "list">, input: Omit<SessionListInput, "cursor">) {
|
||||
const load = async (cursor?: string): Promise<Session[]> => {
|
||||
const result = await api.list({ ...input, limit: input.limit ?? 100, cursor })
|
||||
const sessions = result.data.map(normalizeSessionInfo)
|
||||
if (result.data.length === 0 || !result.cursor.next) return sessions
|
||||
return [...sessions, ...(await load(result.cursor.next))]
|
||||
}
|
||||
return load()
|
||||
}
|
||||
Binary file not shown.
@@ -72,7 +72,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
||||
@@ -26,257 +26,3 @@ test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
|
||||
|
||||
expect(body?.prompt_cache_key).toBe("session-123")
|
||||
})
|
||||
|
||||
test("Mistral round-trips native reasoning in assistant history", async () => {
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
return Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "The user is greeting me." },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
|
||||
const first = await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
const reasoning = first.content.find((part) => part.type === "reasoning")
|
||||
const text = first.content.find((part) => part.type === "text")
|
||||
if (!reasoning || !text) throw new Error("expected reasoning and text")
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(body?.messages?.[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "The user is greeting me." },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
})
|
||||
|
||||
await model.doGenerate({
|
||||
prompt: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hi" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Hello again" }] },
|
||||
],
|
||||
})
|
||||
expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" })
|
||||
})
|
||||
|
||||
test("Mistral preserves native reasoning metadata while streaming", async () => {
|
||||
const chunks = [
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "reference", reference_ids: [1, "source-2"] }],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
]
|
||||
const mockFetch = Object.assign(
|
||||
async () =>
|
||||
new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
const result = await model.doStream({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
const events = []
|
||||
for await (const event of result.stream) events.push(event)
|
||||
|
||||
expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta")
|
||||
.every((event) => event.providerMetadata === undefined),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("Mistral preserves metadata-only thinking chunks", async () => {
|
||||
const thinking = {
|
||||
type: "thinking" as const,
|
||||
thinking: [
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
}
|
||||
const mockFetch = Object.assign(
|
||||
async () =>
|
||||
Response.json({
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "mistral-small-latest",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
}),
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest")
|
||||
const result = await model.doGenerate({
|
||||
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
providerMetadata: { mistral: { thinking } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -182,9 +182,9 @@ export async function spawnLocalServer(
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
let healthUrls: URL[]
|
||||
let healthUrl: URL
|
||||
try {
|
||||
healthUrls = [new URL("/api/health", url), new URL("/global/health", url)]
|
||||
healthUrl = new URL("/global/health", url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -195,17 +195,16 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
headers.set("authorization", `Basic ${auth}`)
|
||||
}
|
||||
|
||||
for (const healthUrl of healthUrls) {
|
||||
try {
|
||||
const res = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (res.ok) return true
|
||||
} catch {}
|
||||
try {
|
||||
const res = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function createSidecarEnv(): Record<string, string> {
|
||||
|
||||
@@ -46,8 +46,12 @@ type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInpu
|
||||
const OpenAIResponsesOutputText = Schema.Struct({
|
||||
type: Schema.tag("output_text"),
|
||||
text: Schema.String,
|
||||
annotations: Schema.Array(Schema.Unknown),
|
||||
})
|
||||
|
||||
const OpenAIResponsesMessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type OpenAIResponsesMessagePhase = Schema.Schema.Type<typeof OpenAIResponsesMessagePhase>
|
||||
|
||||
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -78,7 +82,19 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.String,
|
||||
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.String,
|
||||
status: Schema.Literals(["in_progress", "completed", "incomplete"]),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenAIResponsesOutputText),
|
||||
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||
}),
|
||||
OpenAIResponsesReasoningItem,
|
||||
OpenAIResponsesItemReference,
|
||||
Schema.Struct({
|
||||
@@ -194,7 +210,9 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
||||
server_label: Schema.optional(Schema.String),
|
||||
output: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
content: Schema.optional(Schema.Array(Schema.Unknown)),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
phase: optionalNull(OpenAIResponsesMessagePhase),
|
||||
})
|
||||
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
||||
|
||||
@@ -212,7 +230,9 @@ const OpenAIResponsesErrorPayload = Schema.Struct({
|
||||
const OpenAIResponsesEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
content_index: Schema.optional(Schema.Number),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(OpenAIResponsesStreamItem),
|
||||
response: Schema.optional(
|
||||
@@ -237,10 +257,18 @@ interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: Readonly<Record<string, MessageStreamItem>>
|
||||
readonly messageContentIDs: ReadonlySet<string>
|
||||
readonly nextMessageContentID: number
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
|
||||
interface MessageStreamItem {
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly content: Readonly<Record<number, { readonly id: string; readonly text: string }>>
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
@@ -298,6 +326,26 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un
|
||||
}
|
||||
}
|
||||
|
||||
const messagePhase = (part: TextPart): OpenAIResponsesMessagePhase | null | undefined => {
|
||||
const phase = part.providerMetadata?.openai?.phase
|
||||
return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined
|
||||
}
|
||||
|
||||
const messageItemID = (part: TextPart) => {
|
||||
const itemID = part.providerMetadata?.openai?.itemId
|
||||
return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined
|
||||
}
|
||||
|
||||
const messageStatus = (part: TextPart) => {
|
||||
const status = part.providerMetadata?.openai?.status
|
||||
return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined
|
||||
}
|
||||
|
||||
const messageAnnotations = (part: TextPart) => {
|
||||
const annotations = part.providerMetadata?.openai?.annotations
|
||||
return Array.isArray(annotations) ? annotations : []
|
||||
}
|
||||
|
||||
const hostedToolItemID = (part: ToolResultPart) => {
|
||||
const openai = part.providerMetadata?.openai
|
||||
return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0
|
||||
@@ -369,16 +417,47 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
let phase: OpenAIResponsesMessagePhase | null | undefined
|
||||
let itemID: string | undefined
|
||||
let status: "in_progress" | "completed" | "incomplete" | undefined
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningReplay> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||
input.push(
|
||||
itemID
|
||||
? {
|
||||
type: "message",
|
||||
id: itemID,
|
||||
status: status ?? "completed",
|
||||
role: "assistant",
|
||||
content: content.map((part) => ({
|
||||
type: "output_text",
|
||||
text: part.text,
|
||||
annotations: messageAnnotations(part),
|
||||
})),
|
||||
...(phase !== undefined ? { phase } : {}),
|
||||
}
|
||||
: {
|
||||
role: "assistant",
|
||||
content: ProviderShared.joinText(content),
|
||||
...(phase !== undefined ? { phase } : {}),
|
||||
},
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
phase = undefined
|
||||
itemID = undefined
|
||||
status = undefined
|
||||
}
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
const nextPhase = messagePhase(part)
|
||||
const nextItemID = messageItemID(part)
|
||||
if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText()
|
||||
phase = nextPhase
|
||||
itemID = nextItemID
|
||||
status = messageStatus(part) ?? status
|
||||
content.push(part)
|
||||
continue
|
||||
}
|
||||
@@ -612,15 +691,127 @@ const NO_EVENTS: StepResult["1"] = []
|
||||
// the protocol's `terminal` predicate stay in sync.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => {
|
||||
const openai = previous?.openai
|
||||
const phase = item.phase !== undefined ? item.phase : openai?.phase
|
||||
const status =
|
||||
item.status === "in_progress" || item.status === "completed" || item.status === "incomplete"
|
||||
? item.status
|
||||
: openai?.status
|
||||
return openaiMetadata({
|
||||
itemId: id,
|
||||
...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}),
|
||||
...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const messageContentMetadata = (
|
||||
providerMetadata: ProviderMetadata,
|
||||
item: OpenAIResponsesStreamItem,
|
||||
index: number,
|
||||
): ProviderMetadata => {
|
||||
const content = item.content?.[index]
|
||||
if (!ProviderShared.isRecord(content) || content.type !== "output_text" || !Array.isArray(content.annotations))
|
||||
return providerMetadata
|
||||
return openaiMetadata({ ...providerMetadata.openai, annotations: content.annotations })
|
||||
}
|
||||
|
||||
const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
const itemID = event.item_id ?? "text-0"
|
||||
const index = event.content_index ?? 0
|
||||
const item = state.messageItems[itemID] ?? { content: {} }
|
||||
const existing = item.content[index]
|
||||
if (existing) return { state, itemID, index, item, content: existing }
|
||||
const findID = (next: number): readonly [string, number] => {
|
||||
const id = `openai-text-${next}`
|
||||
return state.messageContentIDs.has(id) ? findID(next + 1) : [id, next + 1]
|
||||
}
|
||||
const [id, nextMessageContentID] =
|
||||
index === 0 && !state.messageContentIDs.has(itemID)
|
||||
? ([itemID, state.nextMessageContentID] as const)
|
||||
: findID(state.nextMessageContentID)
|
||||
const content = { id, text: "" }
|
||||
const nextItem = { ...item, content: { ...item.content, [index]: content } }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
messageItems: { ...state.messageItems, [itemID]: nextItem },
|
||||
messageContentIDs: new Set([...state.messageContentIDs, id]),
|
||||
nextMessageContentID,
|
||||
},
|
||||
itemID,
|
||||
index,
|
||||
item: nextItem,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
const updateMessageContent = (
|
||||
state: ParserState,
|
||||
itemID: string,
|
||||
index: number,
|
||||
content: { readonly id: string; readonly text: string },
|
||||
): ParserState => ({
|
||||
...state,
|
||||
messageItems: {
|
||||
...state.messageItems,
|
||||
[itemID]: {
|
||||
...state.messageItems[itemID],
|
||||
content: { ...state.messageItems[itemID]?.content, [index]: content },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const appendOutputText = (state: ParserState, event: OpenAIResponsesEvent, text: string): StepResult => {
|
||||
const ensured = ensureMessageContent(state, event)
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.textStart(
|
||||
ensured.state.lifecycle,
|
||||
events,
|
||||
ensured.content.id,
|
||||
ensured.item.providerMetadata,
|
||||
)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
||||
{
|
||||
...updateMessageContent(ensured.state, ensured.itemID, ensured.index, {
|
||||
...ensured.content,
|
||||
text: ensured.content.text + text,
|
||||
}),
|
||||
lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
return appendOutputText(state, event, event.delta)
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (event.text === undefined) return [state, NO_EVENTS]
|
||||
const ensured = ensureMessageContent(state, event)
|
||||
if (event.text === ensured.content.text) {
|
||||
if (ensured.state.lifecycle.text.has(ensured.content.id)) return [ensured.state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...ensured.state,
|
||||
lifecycle: Lifecycle.textStart(
|
||||
ensured.state.lifecycle,
|
||||
events,
|
||||
ensured.content.id,
|
||||
ensured.item.providerMetadata,
|
||||
),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (event.text.startsWith(ensured.content.text))
|
||||
return appendOutputText(ensured.state, event, event.text.slice(ensured.content.text.length))
|
||||
return [ensured.state, NO_EVENTS]
|
||||
}
|
||||
|
||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
@@ -655,6 +846,23 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id) {
|
||||
const existing = state.messageItems[item.id]
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: {
|
||||
...state.messageItems,
|
||||
[item.id]: {
|
||||
...existing,
|
||||
providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata),
|
||||
content: existing?.content ?? {},
|
||||
},
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -812,6 +1020,32 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
const events: LLMEvent[] = []
|
||||
const itemID = item.id
|
||||
const messageItem = state.messageItems[itemID]
|
||||
const { [itemID]: _finished, ...messageItems } = state.messageItems
|
||||
const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata)
|
||||
const lifecycle = Object.entries(messageItem?.content ?? {}).reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.textEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
entry[1].id,
|
||||
messageContentMetadata(providerMetadata, item, Number(entry[0])),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
messageItems,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tools = state.tools[item.id]
|
||||
@@ -939,6 +1173,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
@@ -968,6 +1203,9 @@ export const protocol = Protocol.make({
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: {},
|
||||
messageContentIDs: new Set<string>(),
|
||||
nextMessageContentID: 0,
|
||||
reasoningItems: {},
|
||||
store: OpenAIOptions.store(request),
|
||||
}),
|
||||
|
||||
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
return { ...state, stepStarted: true }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.text.has(id)) {
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
||||
events.push(LLMEvent.textStart({ id, ...(providerMetadata ? { providerMetadata } : {}) }))
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
export const reasoningStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
@@ -65,7 +68,7 @@ export const reasoningEnd = (
|
||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (!state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
events.push(LLMEvent.textEnd({ id, ...(providerMetadata ? { providerMetadata } : {}) }))
|
||||
const text = new Set(stepped.text)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
|
||||
+52
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
@@ -0,0 +1,71 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { weatherTool } from "../recorded-scenarios"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = OpenAI.configure({
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
}).responses("gpt-5.6-sol")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-responses-phase",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("OpenAI Responses phase recorded", () => {
|
||||
recorded.effect.with("round-trips commentary into a final answer", { tags: ["phase", "tool"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const user = Message.user("What is the weather in Paris?")
|
||||
const first = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
system:
|
||||
"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.",
|
||||
messages: [user],
|
||||
tools: [weatherTool],
|
||||
generation: { maxTokens: 100 },
|
||||
}),
|
||||
)
|
||||
const call = first.toolCalls[0]
|
||||
if (!call) throw new Error("OpenAI Responses did not return the expected weather tool call")
|
||||
|
||||
expect(call).toMatchObject({ name: "get_weather", input: { city: "Paris" } })
|
||||
expect(
|
||||
first.message.content.some(
|
||||
(part) => part.type === "text" && part.providerMetadata?.openai?.phase === "commentary",
|
||||
),
|
||||
).toBeTrue()
|
||||
|
||||
const second = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
system:
|
||||
"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.",
|
||||
messages: [
|
||||
user,
|
||||
first.message,
|
||||
Message.tool({
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: { temperature: 22, condition: "sunny" },
|
||||
}),
|
||||
],
|
||||
tools: [weatherTool],
|
||||
generation: { maxTokens: 100 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(second.text.trim()).toBe("Paris is sunny.")
|
||||
expect(
|
||||
second.message.content.some(
|
||||
(part) => part.type === "text" && part.providerMetadata?.openai?.phase === "final_answer",
|
||||
),
|
||||
).toBeTrue()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -153,7 +153,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{ role: "assistant", content: "After." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -529,11 +529,11 @@ describe("OpenAI Responses route", () => {
|
||||
encrypted_content: "encrypted-continuation-state",
|
||||
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
|
||||
{ role: "assistant", content: "It shows a small test image." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
|
||||
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
|
||||
{ role: "assistant", content: "Paris is 22 degrees." },
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
|
||||
@@ -754,6 +754,311 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves streamed assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking first." },
|
||||
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_final", delta: "Finished." },
|
||||
{ type: "response.output_text.done", item_id: "msg_final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_commentary",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_commentary", text: "Checking first." },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_commentary",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text-start",
|
||||
id: "msg_final",
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
{ type: "text-delta", id: "msg_final", text: "Finished." },
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_final",
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking first.",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves phased message and content boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.delta",
|
||||
item_id: "msg_commentary",
|
||||
content_index: 0,
|
||||
delta: "First.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "msg_commentary",
|
||||
content_index: 0,
|
||||
text: "First.",
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "msg_commentary",
|
||||
content_index: 1,
|
||||
text: "Second.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary_2", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.delta",
|
||||
item_id: "msg_commentary_2",
|
||||
content_index: 0,
|
||||
delta: "Thi",
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "msg_commentary_2",
|
||||
content_index: 0,
|
||||
text: "Third.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary_2", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "openai-text-0" },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "openai-text-0",
|
||||
content_index: 0,
|
||||
text: "Final.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "openai-text-0",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "Final.",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
start_index: 0,
|
||||
end_index: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_null", phase: null },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "msg_null",
|
||||
content_index: 0,
|
||||
text: "Nullable.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_null", phase: null },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_unphased" },
|
||||
},
|
||||
{
|
||||
type: "response.output_text.done",
|
||||
item_id: "msg_unphased",
|
||||
content_index: 0,
|
||||
text: "Unphased.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_unphased" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "First.",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Second.",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Third.",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Final.",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "openai-text-0",
|
||||
phase: "final_answer",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
start_index: 0,
|
||||
end_index: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Nullable.",
|
||||
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unphased.",
|
||||
providerMetadata: { openai: { itemId: "msg_unphased" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_commentary",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [
|
||||
{ type: "output_text", text: "First.", annotations: [] },
|
||||
{ type: "output_text", text: "Second.", annotations: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_commentary_2",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Third.", annotations: [] }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "openai-text-0",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: "Final.",
|
||||
annotations: [
|
||||
{
|
||||
type: "url_citation",
|
||||
url: "https://example.com",
|
||||
title: "Example",
|
||||
start_index: 0,
|
||||
end_index: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_null",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: null,
|
||||
content: [{ type: "output_text", text: "Nullable.", annotations: [] }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_unphased",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unphased.", annotations: [] }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses reasoning summary stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -947,7 +1252,7 @@ describe("OpenAI Responses route", () => {
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||
{ role: "assistant", content: "The parser changed." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
})
|
||||
@@ -995,13 +1300,69 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{ role: "assistant", content: "Before." },
|
||||
{
|
||||
type: "reasoning",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{ role: "assistant", content: "After." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking first.",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Still checking.",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_commentary",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Checking first.", annotations: [] }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_commentary_2",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: "Still checking.", annotations: [] }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_final",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
phase: "final_answer",
|
||||
content: [{ type: "output_text", text: "Finished.", annotations: [] }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1131,7 +1492,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||
{ role: "assistant", content: "The parser changed." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
store: false,
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"@ai-sdk/google": "3.0.73",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/mistral": "3.0.34",
|
||||
"@ai-sdk/openai": "3.0.84",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
||||
@@ -119,7 +119,7 @@ const storedSession = {
|
||||
|
||||
const openAIResponses = {
|
||||
user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
|
||||
assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
|
||||
assistant: (text: string) => ({ role: "assistant", content: text }),
|
||||
openaiReasoning: (text: string, encryptedContent: string) => ({
|
||||
type: "reasoning",
|
||||
encrypted_content: encryptedContent,
|
||||
|
||||
@@ -906,117 +906,6 @@ describe("session.llm.stream", () => {
|
||||
},
|
||||
)
|
||||
|
||||
const mistralFixture = { providerID: "mistral", modelID: "mistral-small-latest" }
|
||||
it.instance(
|
||||
"replays native Mistral reasoning from chat history",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = loadFixture(mistralFixture.providerID, mistralFixture.modelID)
|
||||
const request = waitRequest(
|
||||
"/chat/completions",
|
||||
createEventResponse(
|
||||
[
|
||||
{
|
||||
id: "chatcmpl-mistral",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: fixture.model.id,
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-mistral",
|
||||
object: "chat.completion.chunk",
|
||||
created: 0,
|
||||
model: fixture.model.id,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
],
|
||||
true,
|
||||
),
|
||||
)
|
||||
|
||||
const resolved = yield* Provider.use.getModel(
|
||||
ProviderV2.ID.make(mistralFixture.providerID),
|
||||
ModelV2.ID.make(fixture.model.id),
|
||||
)
|
||||
const sessionID = SessionID.make("session-test-mistral-reasoning")
|
||||
const agent = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
} satisfies Agent.Info
|
||||
|
||||
const user = {
|
||||
id: MessageID.make("msg_user-mistral-reasoning"),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: agent.name,
|
||||
model: { providerID: ProviderV2.ID.make(mistralFixture.providerID), modelID: resolved.id },
|
||||
} satisfies SessionV1.User
|
||||
|
||||
const thinking = {
|
||||
type: "thinking",
|
||||
thinking: [
|
||||
{ type: "text", text: "thinking" },
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool: "web_search",
|
||||
title: "Example result",
|
||||
url: "https://example.com/tool",
|
||||
favicon: "https://example.com/favicon.ico",
|
||||
description: "Example description",
|
||||
},
|
||||
{ type: "reference", reference_ids: [1, "source-2"] },
|
||||
],
|
||||
closed: true,
|
||||
signature: "sig-123",
|
||||
}
|
||||
|
||||
yield* drain({
|
||||
user,
|
||||
sessionID,
|
||||
model: resolved,
|
||||
agent,
|
||||
system: ["You are a helpful assistant."],
|
||||
messages: [
|
||||
{ role: "user", content: "Hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerOptions: { mistral: { thinking } },
|
||||
},
|
||||
{ type: "text", text: "Previous answer" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "Continue" },
|
||||
] satisfies ModelMessage[],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const capture = yield* Effect.promise(() => request)
|
||||
const messages = capture.body.messages as Array<Record<string, unknown>>
|
||||
expect(messages.find((message) => message.role === "assistant")).toEqual({
|
||||
role: "assistant",
|
||||
content: [thinking, { type: "text", text: "Previous answer" }],
|
||||
})
|
||||
}),
|
||||
{
|
||||
config: () => ({
|
||||
enabled_providers: [mistralFixture.providerID],
|
||||
provider: {
|
||||
[mistralFixture.providerID]: {
|
||||
options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" }
|
||||
it.instance(
|
||||
"service stream cancellation cancels provider response body promptly",
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
@@ -109,7 +109,6 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -138,7 +137,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -212,7 +210,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- MiMo-V2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Laguna S 2.1 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Ling-3.0-flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- North Mini Code Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Nemotron 3 Ultra Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
- Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج.
|
||||
@@ -270,7 +267,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- MiMo-V2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Laguna S 2.1 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- Ling-3.0-flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج.
|
||||
- North Mini Code Free: خلال فترته المجانية، قد يُحتفَظ بالبيانات المُجمَّعة وتُستخدم لتحسين النموذج. لا تُرسل بيانات شخصية أو سرية. راجع [شروط الاستخدام](https://cohere.com/terms-of-use) و[سياسة الخصوصية](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (نقاط نهاية NVIDIA المجانية): للاستخدام التجريبي فقط — لا ترسل بيانات شخصية أو سرية. يُسجَّل استخدامك لأغراض أمنية ولتحسين منتجات وخدمات NVIDIA. بيانات الجلسة المُسجَّلة لأغراض التحسين غير مرتبطة بهويتك أو بأي مُعرِّف دائم. لمزيد من المعلومات حول ممارسات معالجة البيانات لدينا، راجع [سياسة الخصوصية](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). بتفاعلك مع نقطة النهاية هذه، فإنك توافق على جمعنا لهذه المعلومات وتسجيلها واستخدامها وعلى [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,7 +114,6 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ Besplatni modeli:
|
||||
- DeepSeek V4 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- MiMo-V2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Laguna S 2.1 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Ling-3.0-flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- North Mini Code Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Nemotron 3 Ultra Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
- Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model.
|
||||
@@ -282,7 +279,6 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke:
|
||||
- DeepSeek V4 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- MiMo-V2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Laguna S 2.1 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- Ling-3.0-flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela.
|
||||
- North Mini Code Free: Tokom besplatnog perioda, prikupljeni podaci mogu biti zadržani i korišteni za poboljšanje modela. Nemojte slati lične ili povjerljive podatke. Pogledajte naše [Uslove korištenja](https://cohere.com/terms-of-use) i [Politiku privatnosti](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,7 +114,6 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ De gratis modeller:
|
||||
- DeepSeek V4 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- MiMo-V2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Laguna S 2.1 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Ling-3.0-flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- North Mini Code Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Nemotron 3 Ultra Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
- Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen.
|
||||
@@ -280,7 +277,6 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti
|
||||
- DeepSeek V4 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- MiMo-V2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Laguna S 2.1 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- Ling-3.0-flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen.
|
||||
- North Mini Code Free: I gratisperioden kan indsamlede data blive opbevaret og brugt til at forbedre modellen. Indsend ikke personlige eller fortrolige oplysninger. Se vores [Brugsvilkår](https://cohere.com/terms-of-use) og [Privatlivspolitik](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endpoints): Kun til prøvebrug — indsend ikke personlige eller fortrolige data. Din brug logges af sikkerhedshensyn og for at forbedre NVIDIAs produkter og tjenester. De loggede sessionsdata, der bruges til forbedringsformål, er ikke knyttet til din identitet eller nogen vedvarende identifikator. For mere information om vores databehandlingspraksis, se vores [privatlivspolitik](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved at interagere med dette endpoint giver du samtykke til vores indsamling, registrering og brug af sådanne oplysninger samt [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,7 +105,6 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ Die kostenlosen Modelle:
|
||||
- DeepSeek V4 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- MiMo-V2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Laguna S 2.1 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Ling-3.0-flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- North Mini Code Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Nemotron 3 Ultra Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
- Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern.
|
||||
@@ -266,7 +263,6 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer
|
||||
- DeepSeek V4 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- MiMo-V2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Laguna S 2.1 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- Ling-3.0-flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.
|
||||
- North Mini Code Free: Während des kostenlosen Zeitraums können erhobene Daten gespeichert und zur Verbesserung des Modells verwendet werden. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Weitere Informationen finden Sie in unseren [Nutzungsbedingungen](https://cohere.com/terms-of-use) und unserer [Datenschutzerklärung](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (kostenlose NVIDIA-Endpunkte): Nur für Testzwecke — übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Ihre Nutzung wird zu Sicherheitszwecken und zur Verbesserung der Produkte und Dienste von NVIDIA protokolliert. Die zu Verbesserungszwecken protokollierten Sitzungsdaten sind nicht mit Ihrer Identität oder einem dauerhaften Identifikator verknüpft. Weitere Informationen zu unseren Datenverarbeitungspraktiken finden Sie in unserer [Datenschutzrichtlinie](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Durch die Interaktion mit diesem Endpunkt stimmen Sie unserer Erhebung, Aufzeichnung und Nutzung solcher Informationen sowie den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) zu.
|
||||
- OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert.
|
||||
|
||||
@@ -114,7 +114,6 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ Los modelos gratuitos:
|
||||
- DeepSeek V4 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- MiMo-V2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Laguna S 2.1 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Ling-3.0-flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- North Mini Code Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Nemotron 3 Ultra Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
- Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo.
|
||||
@@ -280,7 +277,6 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po
|
||||
- DeepSeek V4 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- MiMo-V2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Laguna S 2.1 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- Ling-3.0-flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo.
|
||||
- North Mini Code Free: Durante el período gratuito, los datos recopilados podrán conservarse y utilizarse para mejorar el modelo. No envíes datos personales ni confidenciales. Consulta nuestros [Términos de uso](https://cohere.com/terms-of-use) y nuestra [Política de privacidad](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos de NVIDIA): Solo para uso de prueba — no envíes datos personales ni confidenciales. Tu uso se registra con fines de seguridad y para mejorar los productos y servicios de NVIDIA. Los datos de sesión registrados con fines de mejora no están vinculados a tu identidad ni a ningún identificador persistente. Para obtener más información sobre nuestras prácticas de procesamiento de datos, consulta nuestra [Política de privacidad](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Al interactuar con este endpoint, aceptas que recopilemos, registremos y usemos dicha información, así como los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,7 +105,6 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ Les modèles gratuits :
|
||||
- DeepSeek V4 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- MiMo-V2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Laguna S 2.1 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Ling-3.0-flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- North Mini Code Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Nemotron 3 Ultra Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
- Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle.
|
||||
@@ -266,7 +263,6 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique
|
||||
- DeepSeek V4 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- MiMo-V2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Laguna S 2.1 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- Ling-3.0-flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.
|
||||
- North Mini Code Free : Pendant la période de gratuité, les données collectées peuvent être conservées et utilisées pour améliorer le modèle. Ne transmettez aucune donnée personnelle ou confidentielle. Consultez nos [Conditions d’utilisation](https://cohere.com/terms-of-use) et notre [Politique de confidentialité](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints NVIDIA gratuits) : Réservé à un usage d'essai — n'envoyez pas de données personnelles ou confidentielles. Votre utilisation est journalisée à des fins de sécurité et pour améliorer les produits et services de NVIDIA. Les données de session journalisées à des fins d'amélioration ne sont pas liées à votre identité ni à un quelconque identifiant persistant. Pour plus d'informations sur nos pratiques de traitement des données, consultez notre [Politique de confidentialité](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). En interagissant avec cet endpoint, vous consentez à notre collecte, à notre enregistrement et à notre utilisation de ces informations ainsi qu'aux [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,7 +114,6 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ I modelli gratuiti:
|
||||
- DeepSeek V4 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- MiMo-V2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Laguna S 2.1 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Ling-3.0-flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- North Mini Code Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Nemotron 3 Ultra Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
- Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello.
|
||||
@@ -280,7 +277,6 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol
|
||||
- DeepSeek V4 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- MiMo-V2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Laguna S 2.1 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- Ling-3.0-flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello.
|
||||
- North Mini Code Free: Durante il periodo gratuito, i dati raccolti possono essere conservati e utilizzati per migliorare il modello. Non inviare dati personali o riservati. Consulta i nostri [Termini di utilizzo](https://cohere.com/terms-of-use) e la nostra [Informativa sulla privacy](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoint NVIDIA gratuiti): solo per uso di prova — non inviare dati personali o riservati. Il tuo utilizzo viene registrato per finalità di sicurezza e per migliorare i prodotti e i servizi di NVIDIA. I dati di sessione registrati a fini di miglioramento non sono collegati alla tua identità né ad alcun identificatore persistente. Per maggiori informazioni sulle nostre pratiche di trattamento dei dati, consulta la nostra [Informativa sulla privacy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interagendo con questo endpoint, acconsenti alla nostra raccolta, registrazione e utilizzo di tali informazioni e ai [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,7 +105,6 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- MiMo-V2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Laguna S 2.1 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Ling-3.0-flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- North Mini Code Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Nemotron 3 Ultra Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
- Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。
|
||||
@@ -266,7 +263,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- MiMo-V2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Laguna S 2.1 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- Ling-3.0-flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。
|
||||
- North Mini Code Free: 無料提供期間中、収集されたデータは保持され、モデルの改善に使用される場合があります。個人情報や機密情報を送信しないでください。詳しくは、[利用規約](https://cohere.com/terms-of-use)および[プライバシーポリシー](https://cohere.com/privacy)をご覧ください。
|
||||
- Nemotron 3 Ultra Free(NVIDIA の無料エンドポイント): 試用専用です — 個人情報や機密データは送信しないでください。お客様の利用は、セキュリティ目的および NVIDIA の製品とサービスの改善のために記録されます。改善目的で記録されたセッションデータは、お客様の身元や永続的な識別子とは関連付けられません。当社のデータ処理慣行の詳細については、[プライバシーポリシー](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)をご覧ください。このエンドポイントを利用することで、お客様はそのような情報の当社による収集、記録、利用、および [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に同意したものとみなされます。
|
||||
- OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。
|
||||
|
||||
@@ -105,7 +105,6 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- MiMo-V2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Laguna S 2.1 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Ling-3.0-flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- North Mini Code Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Nemotron 3 Ultra Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
- Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다.
|
||||
@@ -266,7 +263,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- MiMo-V2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Laguna S 2.1 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- Ling-3.0-flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다.
|
||||
- North Mini Code Free: 무료 제공 기간 동안 수집된 데이터는 보관되며 모델 개선에 사용될 수 있습니다. 개인 정보나 기밀 정보를 제출하지 마세요. 자세한 내용은 [이용 약관](https://cohere.com/terms-of-use) 및 [개인정보 처리방침](https://cohere.com/privacy)을 참조하세요.
|
||||
- Nemotron 3 Ultra Free(NVIDIA 무료 엔드포인트): 평가판 전용이며 — 개인 정보나 기밀 데이터는 제출하지 마세요. 사용 내역은 보안 목적과 NVIDIA 제품 및 서비스 개선을 위해 기록됩니다. 개선 목적으로 기록된 세션 데이터는 사용자의 신원이나 영구 식별자와 연결되지 않습니다. 당사의 데이터 처리 관행에 대한 자세한 내용은 [개인정보처리방침](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)을 참조하세요. 이 엔드포인트와 상호 작용함으로써 사용자는 당사가 이러한 정보를 수집, 기록, 사용하는 것과 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 동의하게 됩니다.
|
||||
- OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다.
|
||||
|
||||
@@ -114,7 +114,6 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ Gratis-modellene:
|
||||
- DeepSeek V4 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- MiMo-V2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Laguna S 2.1 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Ling-3.0-flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- North Mini Code Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Nemotron 3 Ultra Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
- Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen.
|
||||
@@ -280,7 +277,6 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer
|
||||
- DeepSeek V4 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- MiMo-V2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Laguna S 2.1 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- Ling-3.0-flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen.
|
||||
- North Mini Code Free: I gratisperioden kan innsamlede data bli oppbevart og brukt til å forbedre modellen. Ikke send inn personopplysninger eller konfidensielle opplysninger. Se våre [Vilkår for bruk](https://cohere.com/terms-of-use) og vår [Personvernerklæring](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endepunkter): Kun for prøvebruk — ikke send inn personopplysninger eller konfidensielle data. Bruken din logges av sikkerhetshensyn og for å forbedre NVIDIAs produkter og tjenester. Sesjonsdataene som logges for forbedringsformål, er ikke knyttet til identiteten din eller noen vedvarende identifikator. For mer informasjon om vår databehandlingspraksis, se vår [personvernerklæring](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved å samhandle med dette endepunktet samtykker du til at vi samler inn, registrerer og bruker slik informasjon, samt til [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,7 +114,6 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ Darmowe modele:
|
||||
- DeepSeek V4 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- MiMo-V2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Laguna S 2.1 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Ling-3.0-flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- North Mini Code Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Nemotron 3 Ultra Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
- Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu.
|
||||
@@ -280,7 +277,6 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero
|
||||
- DeepSeek V4 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- MiMo-V2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Laguna S 2.1 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- Ling-3.0-flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu.
|
||||
- North Mini Code Free: W okresie bezpłatnego dostępu zebrane dane mogą być przechowywane i wykorzystywane do ulepszania modelu. Nie przesyłaj danych osobowych ani poufnych. Zapoznaj się z naszym [Regulaminem korzystania](https://cohere.com/terms-of-use) i [Polityką prywatności](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (darmowe endpointy NVIDIA): Tylko do użytku próbnego — nie przesyłaj danych osobowych ani poufnych. Twoje korzystanie jest rejestrowane w celach bezpieczeństwa oraz w celu ulepszania produktów i usług NVIDIA. Rejestrowane dane sesji wykorzystywane do celów ulepszania nie są powiązane z Twoją tożsamością ani żadnym trwałym identyfikatorem. Aby uzyskać więcej informacji o naszych praktykach przetwarzania danych, zapoznaj się z naszą [Polityką prywatności](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Korzystając z tego endpointu, wyrażasz zgodę na gromadzenie, rejestrowanie i wykorzystywanie przez nas takich informacji oraz na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,7 +105,6 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ Os modelos gratuitos:
|
||||
- DeepSeek V4 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- MiMo-V2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Laguna S 2.1 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Ling-3.0-flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- North Mini Code Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Nemotron 3 Ultra Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
- Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo.
|
||||
@@ -266,7 +263,6 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol
|
||||
- DeepSeek V4 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- MiMo-V2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Laguna S 2.1 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- Ling-3.0-flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo.
|
||||
- North Mini Code Free: Durante o período gratuito, os dados coletados poderão ser retidos e usados para aprimorar o modelo. Não envie dados pessoais ou confidenciais. Consulte nossos [Termos de Uso](https://cohere.com/terms-of-use) e nossa [Política de Privacidade](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos da NVIDIA): Apenas para uso de avaliação — não envie dados pessoais ou confidenciais. Seu uso é registrado para fins de segurança e para melhorar os produtos e serviços da NVIDIA. Os dados de sessão registrados para fins de melhoria não estão vinculados à sua identidade nem a qualquer identificador persistente. Para mais informações sobre nossas práticas de processamento de dados, consulte nossa [Política de Privacidade](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ao interagir com este endpoint, você consente com a nossa coleta, registro e uso dessas informações e com os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -114,7 +114,6 @@ OpenCode Zen работает как любой другой провайдер
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- MiMo-V2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Laguna S 2.1 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Ling-3.0-flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- North Mini Code Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Nemotron 3 Ultra Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
- Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель.
|
||||
@@ -280,7 +277,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- MiMo-V2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Laguna S 2.1 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- Ling-3.0-flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели.
|
||||
- North Mini Code Free: В течение бесплатного периода собранные данные могут храниться и использоваться для улучшения модели. Не отправляйте персональные или конфиденциальные данные. Ознакомьтесь с нашими [Условиями использования](https://cohere.com/terms-of-use) и [Политикой конфиденциальности](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -107,7 +107,6 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -136,7 +135,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -210,7 +208,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- MiMo-V2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Laguna S 2.1 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Ling-3.0-flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- North Mini Code Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Nemotron 3 Ultra Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
- Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล
|
||||
@@ -268,7 +265,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- MiMo-V2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- Laguna S 2.1 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- Ling-3.0-flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล
|
||||
- North Mini Code Free: ในช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกเก็บรักษาและนำไปใช้เพื่อปรับปรุงโมเดล โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลที่เป็นความลับ ดู[ข้อกำหนดการใช้งาน](https://cohere.com/terms-of-use)และ[นโยบายความเป็นส่วนตัว](https://cohere.com/privacy)ของเรา
|
||||
- Nemotron 3 Ultra Free (endpoint ฟรีของ NVIDIA): ใช้สำหรับการทดลองเท่านั้น — โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ การใช้งานของคุณจะถูกบันทึกเพื่อวัตถุประสงค์ด้านความปลอดภัยและเพื่อปรับปรุงผลิตภัณฑ์และบริการของ NVIDIA ข้อมูลเซสชันที่บันทึกไว้เพื่อวัตถุประสงค์ในการปรับปรุงจะไม่เชื่อมโยงกับตัวตนของคุณหรือตัวระบุถาวรใด ๆ สำหรับข้อมูลเพิ่มเติมเกี่ยวกับแนวปฏิบัติในการประมวลผลข้อมูลของเรา โปรดดู [นโยบายความเป็นส่วนตัว](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ของเรา การโต้ตอบกับ endpoint นี้ถือว่าคุณยินยอมให้เราเก็บรวบรวม บันทึก และใช้ข้อมูลดังกล่าว รวมถึง [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)
|
||||
- OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,7 +105,6 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına
|
||||
- DeepSeek V4 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- MiMo-V2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Laguna S 2.1 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Ling-3.0-flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- North Mini Code Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Nemotron 3 Ultra Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
- Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor.
|
||||
@@ -266,7 +263,6 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention
|
||||
- DeepSeek V4 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- MiMo-V2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- Laguna S 2.1 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- Ling-3.0-flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir.
|
||||
- North Mini Code Free: Ücretsiz kullanım süresi boyunca toplanan veriler saklanabilir ve modeli geliştirmek için kullanılabilir. Kişisel veya gizli veriler göndermeyin. [Kullanım Koşullarımıza](https://cohere.com/terms-of-use) ve [Gizlilik Politikamıza](https://cohere.com/privacy) bakın.
|
||||
- Nemotron 3 Ultra Free (ücretsiz NVIDIA uç noktaları): Yalnızca deneme amaçlıdır — kişisel veya gizli veri göndermeyin. Kullanımınız güvenlik amacıyla ve NVIDIA ürünlerini ve hizmetlerini geliştirmek için kaydedilir. Geliştirme amacıyla kaydedilen oturum verileri kimliğinizle veya herhangi bir kalıcı tanımlayıcıyla ilişkilendirilmez. Veri işleme uygulamalarımız hakkında daha fazla bilgi için [Gizlilik Politikamıza](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) bakın. Bu uç noktayla etkileşime geçerek, bu tür bilgileri toplamamıza, kaydetmemize ve kullanmamıza ve [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) koşullarına onay vermiş olursunuz.
|
||||
- OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır.
|
||||
|
||||
@@ -114,7 +114,6 @@ You can also access our models through the following API endpoints.
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -145,7 +144,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -219,7 +217,6 @@ The free models:
|
||||
- DeepSeek V4 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiMo-V2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Laguna S 2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Ling-3.0-flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- North Mini Code Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Nemotron 3 Ultra Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
@@ -280,7 +277,6 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
- DeepSeek V4 Flash Free: During its free period, collected data may be used to improve the model.
|
||||
- MiMo-V2.5 Free: During its free period, collected data may be used to improve the model.
|
||||
- Laguna S 2.1 Free: During its free period, collected data may be used to improve the model.
|
||||
- Ling-3.0-flash Free: During its free period, collected data may be used to improve the model.
|
||||
- North Mini Code Free: During its free period, collected data may be retained and used to improve the model. Do not submit personal or confidential data. See our [Terms of Use](https://cohere.com/terms-of-use) and [Privacy Policy](https://cohere.com/privacy).
|
||||
- Nemotron 3 Ultra Free (NVIDIA free endpoints): Trial use only — do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -105,7 +105,6 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -134,7 +133,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -208,7 +206,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- MiMo-V2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Laguna S 2.1 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Ling-3.0-flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- North Mini Code Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Nemotron 3 Ultra Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
- Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。
|
||||
@@ -266,7 +263,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- MiMo-V2.5 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Laguna S 2.1 Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- Ling-3.0-flash Free:在免费期间,收集的数据可能会被用于改进模型。
|
||||
- North Mini Code Free:免费期间,所收集的数据可能会被保留并用于改进模型。请勿提交个人或机密数据。请参阅我们的[使用条款](https://cohere.com/terms-of-use)和[隐私政策](https://cohere.com/privacy)。
|
||||
- Nemotron 3 Ultra Free(NVIDIA 免费端点):仅供试用 — 请勿提交个人或机密数据。出于安全目的以及为改进 NVIDIA 产品和服务,系统会记录你的使用情况。出于改进目的而记录的会话数据不会与你的身份或任何持久标识符相关联。有关我们数据处理实践的更多信息,请参阅我们的[隐私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。与此端点进行交互,即表示你同意我们收集、记录和使用此类信息,并同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。
|
||||
- OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
|
||||
@@ -109,7 +109,6 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -139,7 +138,6 @@ https://opencode.ai/zen/v1/models
|
||||
| DeepSeek V4 Flash Free | Free | Free | Free | - |
|
||||
| MiMo-V2.5 Free | Free | Free | Free | - |
|
||||
| Laguna S 2.1 Free | Free | Free | Free | - |
|
||||
| Ling-3.0-flash Free | Free | Free | Free | - |
|
||||
| North Mini Code Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Ultra Free | Free | Free | Free | - |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - |
|
||||
@@ -213,7 +211,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- MiMo-V2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Laguna S 2.1 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Ling-3.0-flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- North Mini Code Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Nemotron 3 Ultra Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
- Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。
|
||||
@@ -272,7 +269,6 @@ https://opencode.ai/zen/v1/models
|
||||
- DeepSeek V4 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- MiMo-V2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- Laguna S 2.1 Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- Ling-3.0-flash Free: 在免費期間,收集到的資料可能會用於改進模型。
|
||||
- North Mini Code Free:免費期間,所收集的資料可能會被保留並用於改進模型。請勿提交個人或機密資料。請參閱我們的[使用條款](https://cohere.com/terms-of-use)和[隱私權政策](https://cohere.com/privacy)。
|
||||
- Nemotron 3 Ultra Free(NVIDIA 免費端點):僅供試用 — 請勿提交個人或機密資料。基於安全目的以及為了改進 NVIDIA 產品與服務,系統會記錄你的使用情況。基於改進目的而記錄的工作階段資料不會與你的身分或任何持久識別碼相關聯。有關我們資料處理實務的更多資訊,請參閱我們的[隱私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。與此端點進行互動,即表示你同意我們收集、記錄與使用此類資訊,並同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。
|
||||
- OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index 1ca9113bed2728a616db773a8e08d8d6957447d7..15408ec429dc210b5fa43589d81b69c93bf27b2d 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
|
||||
none: "none";
|
||||
high: "high";
|
||||
}>>;
|
||||
+ promptCacheKey: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type MistralLanguageModelOptions = z.infer<typeof mistralLanguageModelOptions>;
|
||||
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 45735e524aaff54ea058c99c729c5ffd3c507058..6aca5f6f13da0054ede31c1f1a692e4eaed37d34 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -268,7 +268,8 @@ var mistralLanguageModelOptions = import_v4.z.object({
|
||||
* - `'high'`: Enable reasoning
|
||||
* - `'none'`: Disable reasoning
|
||||
*/
|
||||
- reasoningEffort: import_v4.z.enum(["high", "none"]).optional()
|
||||
+ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(),
|
||||
+ promptCacheKey: import_v4.z.string().optional()
|
||||
});
|
||||
|
||||
// src/mistral-error.ts
|
||||
@@ -413,6 +414,7 @@ var MistralChatLanguageModel = class {
|
||||
top_p: topP,
|
||||
random_seed: seed,
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
+ prompt_cache_key: options.promptCacheKey,
|
||||
// response format:
|
||||
response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? {
|
||||
type: "json_schema",
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 4c22df1cd78a1ba81309c8a86ceecefef4ba4aea..30cd3b1f503860109b7fa2107cd1eb17b70c96be 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -256,7 +256,8 @@ var mistralLanguageModelOptions = z.object({
|
||||
* - `'high'`: Enable reasoning
|
||||
* - `'none'`: Disable reasoning
|
||||
*/
|
||||
- reasoningEffort: z.enum(["high", "none"]).optional()
|
||||
+ reasoningEffort: z.enum(["high", "none"]).optional(),
|
||||
+ promptCacheKey: z.string().optional()
|
||||
});
|
||||
|
||||
// src/mistral-error.ts
|
||||
@@ -403,6 +404,7 @@ var MistralChatLanguageModel = class {
|
||||
top_p: topP,
|
||||
random_seed: seed,
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
+ prompt_cache_key: options.promptCacheKey,
|
||||
// response format:
|
||||
response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? {
|
||||
type: "json_schema",
|
||||
diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts
|
||||
index 480c472d534bedbe8897979673453bd1c29a70b7..e46496da94f7d4af9822897202ca6baae67dae3a 100644
|
||||
--- a/src/mistral-chat-language-model.ts
|
||||
+++ b/src/mistral-chat-language-model.ts
|
||||
@@ -129,6 +129,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
top_p: topP,
|
||||
random_seed: seed,
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
+ prompt_cache_key: options.promptCacheKey,
|
||||
|
||||
// response format:
|
||||
response_format:
|
||||
diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts
|
||||
index 80fff45fba2c378fa06962f071946bcd2b882a0b..b4fdfa51f3bf11a4220e010c8aca92482cb0c3db 100644
|
||||
--- a/src/mistral-chat-options.ts
|
||||
+++ b/src/mistral-chat-options.ts
|
||||
@@ -62,6 +62,11 @@ export const mistralLanguageModelOptions = z.object({
|
||||
* - `'none'`: Disable reasoning
|
||||
*/
|
||||
reasoningEffort: z.enum(['high', 'none']).optional(),
|
||||
+
|
||||
+ /**
|
||||
+ * A stable identifier used to route requests with shared prompt prefixes.
|
||||
+ */
|
||||
+ promptCacheKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MistralLanguageModelOptions = z.infer<
|
||||
@@ -1,709 +0,0 @@
|
||||
diff --git a/dist/index.d.mts b/dist/index.d.mts
|
||||
index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644
|
||||
--- a/dist/index.d.mts
|
||||
+++ b/dist/index.d.mts
|
||||
@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
|
||||
none: "none";
|
||||
high: "high";
|
||||
}>>;
|
||||
+ promptCacheKey: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type MistralLanguageModelOptions = z.infer<typeof mistralLanguageModelOptions>;
|
||||
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
|
||||
none: "none";
|
||||
high: "high";
|
||||
}>>;
|
||||
+ promptCacheKey: z.ZodOptional<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type MistralLanguageModelOptions = z.infer<typeof mistralLanguageModelOptions>;
|
||||
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index d3f904c12a1d582cc7b9e9a2d30273e1a8505b28..267f34e20ea392b7a85ad5259d72d50605a6f971 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -128,11 +128,14 @@ function convertToMistralChatMessages(prompt) {
|
||||
}
|
||||
case "assistant": {
|
||||
let text = "";
|
||||
+ const structuredContent = [];
|
||||
+ let hasNativeReasoning = false;
|
||||
const toolCalls = [];
|
||||
for (const part of content) {
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
text += part.text;
|
||||
+ structuredContent.push({ type: "text", text: part.text });
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
@@ -148,6 +151,13 @@ function convertToMistralChatMessages(prompt) {
|
||||
}
|
||||
case "reasoning": {
|
||||
text += part.text;
|
||||
+ const native = part.providerOptions?.mistral?.thinking;
|
||||
+ if (native?.type === "thinking") {
|
||||
+ hasNativeReasoning = true;
|
||||
+ structuredContent.push(native);
|
||||
+ break;
|
||||
+ }
|
||||
+ structuredContent.push({ type: "text", text: part.text });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -159,7 +169,7 @@ function convertToMistralChatMessages(prompt) {
|
||||
}
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
- content: text,
|
||||
+ content: hasNativeReasoning ? structuredContent : text,
|
||||
prefix: isLastMessage ? true : void 0,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : void 0
|
||||
});
|
||||
@@ -268,7 +278,8 @@ var mistralLanguageModelOptions = import_v4.z.object({
|
||||
* - `'high'`: Enable reasoning
|
||||
* - `'none'`: Disable reasoning
|
||||
*/
|
||||
- reasoningEffort: import_v4.z.enum(["high", "none"]).optional()
|
||||
+ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(),
|
||||
+ promptCacheKey: import_v4.z.string().optional()
|
||||
});
|
||||
|
||||
// src/mistral-error.ts
|
||||
@@ -407,6 +418,7 @@ var MistralChatLanguageModel = class {
|
||||
stop: stopSequences,
|
||||
random_seed: seed,
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
+ prompt_cache_key: options.promptCacheKey,
|
||||
// response format:
|
||||
response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? {
|
||||
type: "json_schema",
|
||||
@@ -465,9 +477,11 @@ var MistralChatLanguageModel = class {
|
||||
for (const part of choice.message.content) {
|
||||
if (part.type === "thinking") {
|
||||
const reasoningText = extractReasoningContent(part.thinking);
|
||||
- if (reasoningText.length > 0) {
|
||||
- content.push({ type: "reasoning", text: reasoningText });
|
||||
- }
|
||||
+ content.push({
|
||||
+ type: "reasoning",
|
||||
+ text: reasoningText,
|
||||
+ providerMetadata: { mistral: { thinking: part } }
|
||||
+ });
|
||||
} else if (part.type === "text") {
|
||||
if (part.text.length > 0) {
|
||||
content.push({ type: "text", text: part.text });
|
||||
@@ -528,6 +542,7 @@ var MistralChatLanguageModel = class {
|
||||
let isFirstChunk = true;
|
||||
let activeText = false;
|
||||
let activeReasoningId = null;
|
||||
+ let activeThinking = null;
|
||||
const generateId2 = this.generateId;
|
||||
return {
|
||||
stream: response.pipeThrough(
|
||||
@@ -561,18 +576,19 @@ var MistralChatLanguageModel = class {
|
||||
for (const part of delta.content) {
|
||||
if (part.type === "thinking") {
|
||||
const reasoningDelta = extractReasoningContent(part.thinking);
|
||||
- if (reasoningDelta.length > 0) {
|
||||
- if (activeReasoningId == null) {
|
||||
- if (activeText) {
|
||||
- controller.enqueue({ type: "text-end", id: "0" });
|
||||
- activeText = false;
|
||||
- }
|
||||
- activeReasoningId = generateId2();
|
||||
- controller.enqueue({
|
||||
- type: "reasoning-start",
|
||||
- id: activeReasoningId
|
||||
- });
|
||||
+ activeThinking = mergeThinking(activeThinking, part);
|
||||
+ if (activeReasoningId == null) {
|
||||
+ if (activeText) {
|
||||
+ controller.enqueue({ type: "text-end", id: "0" });
|
||||
+ activeText = false;
|
||||
}
|
||||
+ activeReasoningId = generateId2();
|
||||
+ controller.enqueue({
|
||||
+ type: "reasoning-start",
|
||||
+ id: activeReasoningId
|
||||
+ });
|
||||
+ }
|
||||
+ if (reasoningDelta.length > 0) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-delta",
|
||||
id: activeReasoningId,
|
||||
@@ -587,9 +603,11 @@ var MistralChatLanguageModel = class {
|
||||
if (activeReasoningId != null) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
- id: activeReasoningId
|
||||
+ id: activeReasoningId,
|
||||
+ providerMetadata: { mistral: { thinking: activeThinking } }
|
||||
});
|
||||
activeReasoningId = null;
|
||||
+ activeThinking = null;
|
||||
}
|
||||
controller.enqueue({ type: "text-start", id: "0" });
|
||||
activeText = true;
|
||||
@@ -638,7 +656,8 @@ var MistralChatLanguageModel = class {
|
||||
if (activeReasoningId != null) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
- id: activeReasoningId
|
||||
+ id: activeReasoningId,
|
||||
+ providerMetadata: { mistral: { thinking: activeThinking } }
|
||||
});
|
||||
}
|
||||
if (activeText) {
|
||||
@@ -660,6 +679,13 @@ var MistralChatLanguageModel = class {
|
||||
function extractReasoningContent(thinking) {
|
||||
return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join("");
|
||||
}
|
||||
+function mergeThinking(current, next) {
|
||||
+ if (current === null) return { ...next, thinking: [...next.thinking] };
|
||||
+ current.thinking.push(...next.thinking);
|
||||
+ if (next.closed !== void 0) current.closed = next.closed;
|
||||
+ if (next.signature !== void 0) current.signature = next.signature;
|
||||
+ return current;
|
||||
+}
|
||||
function extractTextContent(content) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
@@ -686,6 +712,30 @@ function extractTextContent(content) {
|
||||
}
|
||||
return textContent.length ? textContent.join("") : void 0;
|
||||
}
|
||||
+var mistralThinkingContentSchema = import_v43.z.discriminatedUnion("type", [
|
||||
+ import_v43.z.object({
|
||||
+ type: import_v43.z.literal("text"),
|
||||
+ text: import_v43.z.string()
|
||||
+ }),
|
||||
+ import_v43.z.object({
|
||||
+ type: import_v43.z.literal("tool_reference"),
|
||||
+ tool: import_v43.z.string(),
|
||||
+ title: import_v43.z.string(),
|
||||
+ url: import_v43.z.string().nullish(),
|
||||
+ favicon: import_v43.z.string().nullish(),
|
||||
+ description: import_v43.z.string().nullish()
|
||||
+ }),
|
||||
+ import_v43.z.object({
|
||||
+ type: import_v43.z.literal("reference"),
|
||||
+ reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number().int()]))
|
||||
+ })
|
||||
+]);
|
||||
+var mistralThinkChunkSchema = import_v43.z.object({
|
||||
+ type: import_v43.z.literal("thinking"),
|
||||
+ thinking: import_v43.z.array(mistralThinkingContentSchema),
|
||||
+ closed: import_v43.z.boolean().optional(),
|
||||
+ signature: import_v43.z.string().nullish()
|
||||
+});
|
||||
var mistralContentSchema = import_v43.z.union([
|
||||
import_v43.z.string(),
|
||||
import_v43.z.array(
|
||||
@@ -708,15 +758,7 @@ var mistralContentSchema = import_v43.z.union([
|
||||
type: import_v43.z.literal("reference"),
|
||||
reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number()]))
|
||||
}),
|
||||
- import_v43.z.object({
|
||||
- type: import_v43.z.literal("thinking"),
|
||||
- thinking: import_v43.z.array(
|
||||
- import_v43.z.object({
|
||||
- type: import_v43.z.literal("text"),
|
||||
- text: import_v43.z.string()
|
||||
- })
|
||||
- )
|
||||
- })
|
||||
+ mistralThinkChunkSchema
|
||||
])
|
||||
)
|
||||
]).nullish();
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index d2eff622c1b84a96bdeb4012cb0206a33012a04d..3bff11ddd6136ada45809568828cbc8f2493a42a 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -116,11 +116,14 @@ function convertToMistralChatMessages(prompt) {
|
||||
}
|
||||
case "assistant": {
|
||||
let text = "";
|
||||
+ const structuredContent = [];
|
||||
+ let hasNativeReasoning = false;
|
||||
const toolCalls = [];
|
||||
for (const part of content) {
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
text += part.text;
|
||||
+ structuredContent.push({ type: "text", text: part.text });
|
||||
break;
|
||||
}
|
||||
case "tool-call": {
|
||||
@@ -136,6 +139,13 @@ function convertToMistralChatMessages(prompt) {
|
||||
}
|
||||
case "reasoning": {
|
||||
text += part.text;
|
||||
+ const native = part.providerOptions?.mistral?.thinking;
|
||||
+ if (native?.type === "thinking") {
|
||||
+ hasNativeReasoning = true;
|
||||
+ structuredContent.push(native);
|
||||
+ break;
|
||||
+ }
|
||||
+ structuredContent.push({ type: "text", text: part.text });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -147,7 +157,7 @@ function convertToMistralChatMessages(prompt) {
|
||||
}
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
- content: text,
|
||||
+ content: hasNativeReasoning ? structuredContent : text,
|
||||
prefix: isLastMessage ? true : void 0,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : void 0
|
||||
});
|
||||
@@ -256,7 +266,8 @@ var mistralLanguageModelOptions = z.object({
|
||||
* - `'high'`: Enable reasoning
|
||||
* - `'none'`: Disable reasoning
|
||||
*/
|
||||
- reasoningEffort: z.enum(["high", "none"]).optional()
|
||||
+ reasoningEffort: z.enum(["high", "none"]).optional(),
|
||||
+ promptCacheKey: z.string().optional()
|
||||
});
|
||||
|
||||
// src/mistral-error.ts
|
||||
@@ -397,6 +408,7 @@ var MistralChatLanguageModel = class {
|
||||
stop: stopSequences,
|
||||
random_seed: seed,
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
+ prompt_cache_key: options.promptCacheKey,
|
||||
// response format:
|
||||
response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? {
|
||||
type: "json_schema",
|
||||
@@ -455,9 +467,11 @@ var MistralChatLanguageModel = class {
|
||||
for (const part of choice.message.content) {
|
||||
if (part.type === "thinking") {
|
||||
const reasoningText = extractReasoningContent(part.thinking);
|
||||
- if (reasoningText.length > 0) {
|
||||
- content.push({ type: "reasoning", text: reasoningText });
|
||||
- }
|
||||
+ content.push({
|
||||
+ type: "reasoning",
|
||||
+ text: reasoningText,
|
||||
+ providerMetadata: { mistral: { thinking: part } }
|
||||
+ });
|
||||
} else if (part.type === "text") {
|
||||
if (part.text.length > 0) {
|
||||
content.push({ type: "text", text: part.text });
|
||||
@@ -518,6 +532,7 @@ var MistralChatLanguageModel = class {
|
||||
let isFirstChunk = true;
|
||||
let activeText = false;
|
||||
let activeReasoningId = null;
|
||||
+ let activeThinking = null;
|
||||
const generateId2 = this.generateId;
|
||||
return {
|
||||
stream: response.pipeThrough(
|
||||
@@ -551,18 +566,19 @@ var MistralChatLanguageModel = class {
|
||||
for (const part of delta.content) {
|
||||
if (part.type === "thinking") {
|
||||
const reasoningDelta = extractReasoningContent(part.thinking);
|
||||
- if (reasoningDelta.length > 0) {
|
||||
- if (activeReasoningId == null) {
|
||||
- if (activeText) {
|
||||
- controller.enqueue({ type: "text-end", id: "0" });
|
||||
- activeText = false;
|
||||
- }
|
||||
- activeReasoningId = generateId2();
|
||||
- controller.enqueue({
|
||||
- type: "reasoning-start",
|
||||
- id: activeReasoningId
|
||||
- });
|
||||
+ activeThinking = mergeThinking(activeThinking, part);
|
||||
+ if (activeReasoningId == null) {
|
||||
+ if (activeText) {
|
||||
+ controller.enqueue({ type: "text-end", id: "0" });
|
||||
+ activeText = false;
|
||||
}
|
||||
+ activeReasoningId = generateId2();
|
||||
+ controller.enqueue({
|
||||
+ type: "reasoning-start",
|
||||
+ id: activeReasoningId
|
||||
+ });
|
||||
+ }
|
||||
+ if (reasoningDelta.length > 0) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-delta",
|
||||
id: activeReasoningId,
|
||||
@@ -577,9 +593,11 @@ var MistralChatLanguageModel = class {
|
||||
if (activeReasoningId != null) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
- id: activeReasoningId
|
||||
+ id: activeReasoningId,
|
||||
+ providerMetadata: { mistral: { thinking: activeThinking } }
|
||||
});
|
||||
activeReasoningId = null;
|
||||
+ activeThinking = null;
|
||||
}
|
||||
controller.enqueue({ type: "text-start", id: "0" });
|
||||
activeText = true;
|
||||
@@ -628,7 +646,8 @@ var MistralChatLanguageModel = class {
|
||||
if (activeReasoningId != null) {
|
||||
controller.enqueue({
|
||||
type: "reasoning-end",
|
||||
- id: activeReasoningId
|
||||
+ id: activeReasoningId,
|
||||
+ providerMetadata: { mistral: { thinking: activeThinking } }
|
||||
});
|
||||
}
|
||||
if (activeText) {
|
||||
@@ -650,6 +669,13 @@ var MistralChatLanguageModel = class {
|
||||
function extractReasoningContent(thinking) {
|
||||
return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join("");
|
||||
}
|
||||
+function mergeThinking(current, next) {
|
||||
+ if (current === null) return { ...next, thinking: [...next.thinking] };
|
||||
+ current.thinking.push(...next.thinking);
|
||||
+ if (next.closed !== void 0) current.closed = next.closed;
|
||||
+ if (next.signature !== void 0) current.signature = next.signature;
|
||||
+ return current;
|
||||
+}
|
||||
function extractTextContent(content) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
@@ -676,6 +702,30 @@ function extractTextContent(content) {
|
||||
}
|
||||
return textContent.length ? textContent.join("") : void 0;
|
||||
}
|
||||
+var mistralThinkingContentSchema = z3.discriminatedUnion("type", [
|
||||
+ z3.object({
|
||||
+ type: z3.literal("text"),
|
||||
+ text: z3.string()
|
||||
+ }),
|
||||
+ z3.object({
|
||||
+ type: z3.literal("tool_reference"),
|
||||
+ tool: z3.string(),
|
||||
+ title: z3.string(),
|
||||
+ url: z3.string().nullish(),
|
||||
+ favicon: z3.string().nullish(),
|
||||
+ description: z3.string().nullish()
|
||||
+ }),
|
||||
+ z3.object({
|
||||
+ type: z3.literal("reference"),
|
||||
+ reference_ids: z3.array(z3.union([z3.string(), z3.number().int()]))
|
||||
+ })
|
||||
+]);
|
||||
+var mistralThinkChunkSchema = z3.object({
|
||||
+ type: z3.literal("thinking"),
|
||||
+ thinking: z3.array(mistralThinkingContentSchema),
|
||||
+ closed: z3.boolean().optional(),
|
||||
+ signature: z3.string().nullish()
|
||||
+});
|
||||
var mistralContentSchema = z3.union([
|
||||
z3.string(),
|
||||
z3.array(
|
||||
@@ -698,15 +748,7 @@ var mistralContentSchema = z3.union([
|
||||
type: z3.literal("reference"),
|
||||
reference_ids: z3.array(z3.union([z3.string(), z3.number()]))
|
||||
}),
|
||||
- z3.object({
|
||||
- type: z3.literal("thinking"),
|
||||
- thinking: z3.array(
|
||||
- z3.object({
|
||||
- type: z3.literal("text"),
|
||||
- text: z3.string()
|
||||
- })
|
||||
- )
|
||||
- })
|
||||
+ mistralThinkChunkSchema
|
||||
])
|
||||
)
|
||||
]).nullish();
|
||||
diff --git a/src/convert-to-mistral-chat-messages.ts b/src/convert-to-mistral-chat-messages.ts
|
||||
index 3c6914f8da615d7517bc43dd56198298d0a50247..8cd6f4c7577f746ef41e8a0aee682234c473667a 100644
|
||||
--- a/src/convert-to-mistral-chat-messages.ts
|
||||
+++ b/src/convert-to-mistral-chat-messages.ts
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
type LanguageModelV3DataContent,
|
||||
type LanguageModelV3Prompt,
|
||||
} from '@ai-sdk/provider';
|
||||
-import type { MistralPrompt } from './mistral-chat-prompt';
|
||||
+import type {
|
||||
+ MistralAssistantMessageContent,
|
||||
+ MistralPrompt,
|
||||
+ MistralThinkChunk,
|
||||
+} from './mistral-chat-prompt';
|
||||
import { convertToBase64 } from '@ai-sdk/provider-utils';
|
||||
|
||||
function formatFileUrl({
|
||||
@@ -76,6 +80,8 @@ export function convertToMistralChatMessages(
|
||||
|
||||
case 'assistant': {
|
||||
let text = '';
|
||||
+ const structuredContent: Array<MistralAssistantMessageContent> = [];
|
||||
+ let hasNativeReasoning = false;
|
||||
const toolCalls: Array<{
|
||||
id: string;
|
||||
type: 'function';
|
||||
@@ -86,6 +92,7 @@ export function convertToMistralChatMessages(
|
||||
switch (part.type) {
|
||||
case 'text': {
|
||||
text += part.text;
|
||||
+ structuredContent.push({ type: 'text', text: part.text });
|
||||
break;
|
||||
}
|
||||
case 'tool-call': {
|
||||
@@ -101,6 +108,14 @@ export function convertToMistralChatMessages(
|
||||
}
|
||||
case 'reasoning': {
|
||||
text += part.text;
|
||||
+ const native = part.providerOptions?.mistral
|
||||
+ ?.thinking as MistralThinkChunk | undefined;
|
||||
+ if (native?.type === 'thinking') {
|
||||
+ hasNativeReasoning = true;
|
||||
+ structuredContent.push(native);
|
||||
+ break;
|
||||
+ }
|
||||
+ structuredContent.push({ type: 'text', text: part.text });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -113,7 +128,7 @@ export function convertToMistralChatMessages(
|
||||
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
- content: text,
|
||||
+ content: hasNativeReasoning ? structuredContent : text,
|
||||
prefix: isLastMessage ? true : undefined,
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts
|
||||
index 7e4a7ab552f1b41b7074e1b3cada8a51d791268d..847d26f9dfe03572a969a122f8c96b8bbfda8066 100644
|
||||
--- a/src/mistral-chat-language-model.ts
|
||||
+++ b/src/mistral-chat-language-model.ts
|
||||
@@ -122,6 +122,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
stop: stopSequences,
|
||||
random_seed: seed,
|
||||
reasoning_effort: options.reasoningEffort,
|
||||
+ prompt_cache_key: options.promptCacheKey,
|
||||
|
||||
// response format:
|
||||
response_format:
|
||||
@@ -201,9 +202,11 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
for (const part of choice.message.content) {
|
||||
if (part.type === 'thinking') {
|
||||
const reasoningText = extractReasoningContent(part.thinking);
|
||||
- if (reasoningText.length > 0) {
|
||||
- content.push({ type: 'reasoning', text: reasoningText });
|
||||
- }
|
||||
+ content.push({
|
||||
+ type: 'reasoning',
|
||||
+ text: reasoningText,
|
||||
+ providerMetadata: { mistral: { thinking: part } },
|
||||
+ });
|
||||
} else if (part.type === 'text') {
|
||||
if (part.text.length > 0) {
|
||||
content.push({ type: 'text', text: part.text });
|
||||
@@ -278,6 +281,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
let isFirstChunk = true;
|
||||
let activeText = false;
|
||||
let activeReasoningId: string | null = null;
|
||||
+ let activeThinking: z.infer<typeof mistralThinkChunkSchema> | null = null;
|
||||
|
||||
const generateId = this.generateId;
|
||||
|
||||
@@ -326,20 +330,21 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
for (const part of delta.content) {
|
||||
if (part.type === 'thinking') {
|
||||
const reasoningDelta = extractReasoningContent(part.thinking);
|
||||
- if (reasoningDelta.length > 0) {
|
||||
- if (activeReasoningId == null) {
|
||||
- // end any active text before starting reasoning
|
||||
- if (activeText) {
|
||||
- controller.enqueue({ type: 'text-end', id: '0' });
|
||||
- activeText = false;
|
||||
- }
|
||||
-
|
||||
- activeReasoningId = generateId();
|
||||
- controller.enqueue({
|
||||
- type: 'reasoning-start',
|
||||
- id: activeReasoningId,
|
||||
- });
|
||||
+ activeThinking = mergeThinking(activeThinking, part);
|
||||
+ if (activeReasoningId == null) {
|
||||
+ // end any active text before starting reasoning
|
||||
+ if (activeText) {
|
||||
+ controller.enqueue({ type: 'text-end', id: '0' });
|
||||
+ activeText = false;
|
||||
}
|
||||
+
|
||||
+ activeReasoningId = generateId();
|
||||
+ controller.enqueue({
|
||||
+ type: 'reasoning-start',
|
||||
+ id: activeReasoningId,
|
||||
+ });
|
||||
+ }
|
||||
+ if (reasoningDelta.length > 0) {
|
||||
controller.enqueue({
|
||||
type: 'reasoning-delta',
|
||||
id: activeReasoningId,
|
||||
@@ -357,8 +362,12 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: 'reasoning-end',
|
||||
id: activeReasoningId,
|
||||
+ providerMetadata: {
|
||||
+ mistral: { thinking: activeThinking },
|
||||
+ },
|
||||
});
|
||||
activeReasoningId = null;
|
||||
+ activeThinking = null;
|
||||
}
|
||||
controller.enqueue({ type: 'text-start', id: '0' });
|
||||
activeText = true;
|
||||
@@ -416,6 +425,9 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: 'reasoning-end',
|
||||
id: activeReasoningId,
|
||||
+ providerMetadata: {
|
||||
+ mistral: { thinking: activeThinking },
|
||||
+ },
|
||||
});
|
||||
}
|
||||
if (activeText) {
|
||||
@@ -437,7 +449,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
function extractReasoningContent(
|
||||
- thinking: Array<{ type: string; text: string }>,
|
||||
+ thinking: Array<z.infer<typeof mistralThinkingContentSchema>>,
|
||||
) {
|
||||
return thinking
|
||||
.filter(chunk => chunk.type === 'text')
|
||||
@@ -445,6 +457,17 @@ function extractReasoningContent(
|
||||
.join('');
|
||||
}
|
||||
|
||||
+function mergeThinking(
|
||||
+ current: z.infer<typeof mistralThinkChunkSchema> | null,
|
||||
+ next: z.infer<typeof mistralThinkChunkSchema>,
|
||||
+) {
|
||||
+ if (current === null) return { ...next, thinking: [...next.thinking] };
|
||||
+ current.thinking.push(...next.thinking);
|
||||
+ if (next.closed !== undefined) current.closed = next.closed;
|
||||
+ if (next.signature !== undefined) current.signature = next.signature;
|
||||
+ return current;
|
||||
+}
|
||||
+
|
||||
function extractTextContent(content: z.infer<typeof mistralContentSchema>) {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
@@ -478,6 +501,32 @@ function extractTextContent(content: z.infer<typeof mistralContentSchema>) {
|
||||
return textContent.length ? textContent.join('') : undefined;
|
||||
}
|
||||
|
||||
+const mistralThinkingContentSchema = z.discriminatedUnion('type', [
|
||||
+ z.object({
|
||||
+ type: z.literal('text'),
|
||||
+ text: z.string(),
|
||||
+ }),
|
||||
+ z.object({
|
||||
+ type: z.literal('tool_reference'),
|
||||
+ tool: z.string(),
|
||||
+ title: z.string(),
|
||||
+ url: z.string().nullish(),
|
||||
+ favicon: z.string().nullish(),
|
||||
+ description: z.string().nullish(),
|
||||
+ }),
|
||||
+ z.object({
|
||||
+ type: z.literal('reference'),
|
||||
+ reference_ids: z.array(z.union([z.string(), z.number().int()])),
|
||||
+ }),
|
||||
+]);
|
||||
+
|
||||
+const mistralThinkChunkSchema = z.object({
|
||||
+ type: z.literal('thinking'),
|
||||
+ thinking: z.array(mistralThinkingContentSchema),
|
||||
+ closed: z.boolean().optional(),
|
||||
+ signature: z.string().nullish(),
|
||||
+});
|
||||
+
|
||||
const mistralContentSchema = z
|
||||
.union([
|
||||
z.string(),
|
||||
@@ -501,15 +550,7 @@ const mistralContentSchema = z
|
||||
type: z.literal('reference'),
|
||||
reference_ids: z.array(z.union([z.string(), z.number()])),
|
||||
}),
|
||||
- z.object({
|
||||
- type: z.literal('thinking'),
|
||||
- thinking: z.array(
|
||||
- z.object({
|
||||
- type: z.literal('text'),
|
||||
- text: z.string(),
|
||||
- }),
|
||||
- ),
|
||||
- }),
|
||||
+ mistralThinkChunkSchema,
|
||||
]),
|
||||
),
|
||||
])
|
||||
diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts
|
||||
index 54b29c08517d348995b6ca093b11160e453d5c8b..de30c3e7d924889339e38b1067cb26e9ada05d11 100644
|
||||
--- a/src/mistral-chat-options.ts
|
||||
+++ b/src/mistral-chat-options.ts
|
||||
@@ -64,6 +64,11 @@ export const mistralLanguageModelOptions = z.object({
|
||||
* - `'none'`: Disable reasoning
|
||||
*/
|
||||
reasoningEffort: z.enum(['high', 'none']).optional(),
|
||||
+
|
||||
+ /**
|
||||
+ * A stable identifier used to route requests with shared prompt prefixes.
|
||||
+ */
|
||||
+ promptCacheKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type MistralLanguageModelOptions = z.infer<
|
||||
diff --git a/src/mistral-chat-prompt.ts b/src/mistral-chat-prompt.ts
|
||||
index 13f1dced55ac4be084128127a57fbdd58115bc28..172b11dde3dd326c2f3befd99237474ed8c79285 100644
|
||||
--- a/src/mistral-chat-prompt.ts
|
||||
+++ b/src/mistral-chat-prompt.ts
|
||||
@@ -23,7 +23,7 @@ export type MistralUserMessageContent =
|
||||
|
||||
export interface MistralAssistantMessage {
|
||||
role: 'assistant';
|
||||
- content: string;
|
||||
+ content: string | Array<MistralAssistantMessageContent>;
|
||||
prefix?: boolean;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
@@ -32,6 +32,29 @@ export interface MistralAssistantMessage {
|
||||
}>;
|
||||
}
|
||||
|
||||
+export type MistralAssistantMessageContent =
|
||||
+ | { type: 'text'; text: string }
|
||||
+ | MistralThinkChunk;
|
||||
+
|
||||
+export type MistralThinkChunk = {
|
||||
+ type: 'thinking';
|
||||
+ thinking: Array<MistralThinkingContent>;
|
||||
+ closed?: boolean;
|
||||
+ signature?: string | null;
|
||||
+};
|
||||
+
|
||||
+export type MistralThinkingContent =
|
||||
+ | { type: 'text'; text: string }
|
||||
+ | {
|
||||
+ type: 'tool_reference';
|
||||
+ tool: string;
|
||||
+ title: string;
|
||||
+ url?: string | null;
|
||||
+ favicon?: string | null;
|
||||
+ description?: string | null;
|
||||
+ }
|
||||
+ | { type: 'reference'; reference_ids: Array<string | number> };
|
||||
+
|
||||
export interface MistralToolMessage {
|
||||
role: 'tool';
|
||||
name: string;
|
||||
Reference in New Issue
Block a user