Просмотр исходного кода

chore: merge dev into v2 (#38563)

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Nabs <nabil@instafork.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Victor Navarro <vn4varro@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com>
Co-authored-by: Mark <geraint0923@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: Jay <air@live.ca>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
opencode-agent[bot] 3 недель назад
Родитель
Сommit
02f2725154

+ 18 - 3
bun.lock

@@ -61,6 +61,7 @@
         "@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:*",
@@ -350,7 +351,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.27",
+        "@ai-sdk/mistral": "3.0.51",
         "@ai-sdk/openai": "3.0.84",
         "@ai-sdk/openai-compatible": "2.0.41",
         "@ai-sdk/perplexity": "3.0.26",
@@ -470,7 +471,9 @@
     "packages/docs": {
       "name": "@opencode-ai/docs",
       "devDependencies": {
+        "effect": "catalog:",
         "mint": "4.2.666",
+        "prettier": "3.6.2",
       },
     },
     "packages/effect-drizzle-sqlite": {
@@ -706,6 +709,7 @@
       "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:*",
@@ -1079,6 +1083,7 @@
     "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
     "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
+    "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.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",
@@ -1205,7 +1210,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.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
+    "@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/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=="],
 
@@ -6151,7 +6156,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-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-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/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=="],
 
@@ -6581,6 +6588,8 @@
 
     "@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/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
 
     "@opencode-ai/console-app/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
@@ -6599,6 +6608,8 @@
 
     "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
 
+    "@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/storybook/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
@@ -6927,6 +6938,8 @@
 
     "aggregate-error/indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="],
 
+    "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
+
     "ai-gateway-provider/@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=="],
 
     "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -8047,6 +8060,8 @@
 
     "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
 
+    "ai-gateway-provider/@ai-sdk/mistral/@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-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
 
     "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "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-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=",
-    "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=",
-    "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=",
-    "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo="
+    "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
+    "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
+    "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
+    "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
   }
 }

+ 1 - 0
package.json

@@ -160,6 +160,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",
     "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",

+ 2 - 0
packages/app/e2e/performance/timeline-stability/fixture.ts

@@ -97,6 +97,7 @@ export async function setupTimeline(
     locale?: string
     deviceScaleFactor?: number
     seedHistory?: boolean
+    protocol?: "v1" | "v2"
   } = {},
 ) {
   const sessions = input.sessions ?? [session()]
@@ -114,6 +115,7 @@ export async function setupTimeline(
     retry: input.eventRetry ?? 20,
   })
   await mockOpenCodeServer(page, {
+    protocol: input.protocol,
     directory,
     project: project(),
     provider: provider(),

+ 3 - 3
packages/app/e2e/regression/session-timeline-transport.spec.ts

@@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
   expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
 })
 
-test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
-  const timeline = await setupTimeline(page, { eventRetry: 10 })
+test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
+  const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
   const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
     id: "timeline-event-7",
   })
@@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
   const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
 
   expect(first.eventID).toBe("timeline-event-7")
-  expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
+  expect(connection.headers["last-event-id"]).toBeUndefined()
 })
 
 test("passes through non-event fetches", async ({ page }) => {

+ 276 - 2
packages/app/e2e/utils/mock-server.ts

@@ -4,6 +4,7 @@ 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
@@ -53,8 +54,20 @@ 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") return sse(route, config.events?.(), config.eventRetry)
-    if (path === "/global/health") return json(route, { healthy: true })
+    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) ?? [])]
+          : 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 === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
@@ -83,10 +96,129 @@ 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])
@@ -107,6 +239,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
 
     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
@@ -129,6 +279,115 @@ 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) {
+  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: {
+      created: "created" in time && typeof time.created === "number" ? time.created : 0,
+      updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
+      ...(session.time && typeof session.time === "object" && "archived" in session.time
+        ? { archived: session.time.archived }
+        : {}),
+    },
+    title: session.title ?? session.id,
+    location: {
+      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: [] },
+        },
+      ]
+    }),
+  }
+}
+
 function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
   return route.fulfill({
     status,
@@ -149,3 +408,18 @@ 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,
+  }
+}

+ 29 - 9
packages/app/e2e/utils/sse-transport.ts

@@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
 export type SseConnectionRecord = {
   id: number
   url: string
-  path: "/global/event" | "/event"
+  path: "/global/event" | "/event" | "/api/event"
   headers: Record<string, string>
   openedAt: number
   endedAt?: number
@@ -93,6 +93,21 @@ 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,
@@ -140,15 +155,13 @@ 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) => ({
-          delivery,
-          bytes: encoder.encode(frame(delivery.payload, delivery.options)),
-        }))
+        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)) }
+        })
         encoded.forEach((item) => marker(item.delivery.options?.marker))
         if (input.burst) {
-          const bytes = encoder.encode(
-            encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
-          )
+          const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
           connection.controller.enqueue(bytes)
           return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
         }
@@ -161,7 +174,10 @@ 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"))
+        if (
+          url.origin !== server ||
+          (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
+        )
           return originalFetch(request)
 
         const id = ++nextConnectionID
@@ -177,6 +193,10 @@ 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: {} })),
+              )
             request.signal.addEventListener(
               "abort",
               () => {

+ 1 - 0
packages/app/package.json

@@ -53,6 +53,7 @@
     "@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:*",

+ 37 - 1
packages/app/src/context/server-sdk.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from "bun:test"
-import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
+import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
+import type { OpenCodeEvent } from "@opencode-ai/client/promise"
 import type { Event } from "@opencode-ai/sdk/v2/client"
 
 describe("resumeStreamAfterPageShow", () => {
@@ -14,6 +15,23 @@ 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",
@@ -34,6 +52,24 @@ 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",

+ 143 - 51
packages/app/src/context/server-sdk.tsx

@@ -1,21 +1,60 @@
+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 { createSdkForServer } from "@/utils/server"
+import { createApiForServer, createSdkForServer, type ServerApi } 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
-type QueuedServerEvent = { directory: string; payload: Event }
+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
+}
 
 const coalescedKey = (event: QueuedServerEvent) => {
   if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
@@ -40,6 +79,34 @@ 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
@@ -71,12 +138,52 @@ 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()
 }
 
-function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) {
+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 {
   const platform = usePlatform()
   const abort = new AbortController()
 
@@ -91,13 +198,15 @@ 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]: Event
+    [key: string]: ServerEvent
   }>()
 
   type Queued = QueuedServerEvent
@@ -142,21 +251,6 @@ 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
@@ -168,35 +262,24 @@ 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 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,
-              })
-            },
-          })
+          const kind = await protocol
+          const events =
+            kind === "v1"
+              ? (await eventSdk.global.event({ signal: attempt.signal })).stream
+              : eventApi.event.subscribe({ signal: attempt.signal })
           let yielded = Date.now()
-          resetHeartbeat()
-          for await (const event of events.stream) {
-            resetHeartbeat()
+          for await (const event of events) {
             streamErrorLogged = false
-            if (event.payload.type !== "sync") {
-              const directory = event.directory ?? "global"
-              const payload = event.payload as Event
-              if (enqueueServerEvent(queue, { directory, payload })) schedule()
-            }
+            const legacy = "payload" in event
+            if (legacy && event.payload.type === "sync") continue
+            const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global")
+            const payload = legacy ? (event.payload as Event) : adaptServerEvent(event)
+            if (enqueueServerEvent(queue, { directory, payload })) schedule()
 
             if (Date.now() - yielded < STREAM_YIELD_MS) continue
             yielded = Date.now()
@@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
         } finally {
           abort.signal.removeEventListener("abort", onAbort)
           attempt = undefined
-          clearHeartbeat()
         }
 
         if (abort.signal.aborted || !started || generation !== active) return
@@ -233,18 +315,11 @@ 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(() => {
@@ -258,12 +333,24 @@ 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),
@@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
   }
 }
 
-type ServerSDKBase = ReturnType<typeof createServerSdkContextBase>
 export type ServerSDK = ServerSDKBase & {
   ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
 }
@@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo
 })
 
 type SDKEventMap = {
-  [key in Event["type"]]: Extract<Event, { type: key }>
+  [key in Event["type"]]: Extract<ServerEvent, { type: key }>
 }
 
 function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
@@ -329,6 +415,12 @@ 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

+ 113 - 0
packages/app/src/utils/server-compat.test.ts

@@ -0,0 +1,113 @@
+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">) {
+  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") 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")
+  })
+})

+ 495 - 0
packages/app/src/utils/server-compat.ts

@@ -0,0 +1,495 @@
+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: undefined }, 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 })
+      },
+    },
+  }
+}

+ 34 - 4
packages/app/src/utils/server-health.test.ts

@@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
 
 describe("checkServerHealth", () => {
   test("returns healthy response with version", async () => {
-    const fetch = (async () =>
-      new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
+    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" }), {
         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 () => {
@@ -142,7 +172,7 @@ describe("checkServerHealth", () => {
       retryDelayMs: 1,
     })
 
-    expect(count).toBe(3)
+    expect(count).toBe(6)
     expect(result).toEqual({ healthy: false })
   })
 })

+ 23 - 5
packages/app/src/utils/server-health.ts

@@ -1,6 +1,7 @@
 import { usePlatform } from "@/context/platform"
 import { ServerConnection } from "@/context/server"
-import { createSdkForServer } from "./server"
+import { authTokenFromCredentials, createSdkForServer } from "./server"
+import { ClientError, OpenCode } from "@opencode-ai/client"
 import { Accessor, createEffect, onCleanup } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 
@@ -61,6 +62,7 @@ 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
@@ -82,15 +84,31 @@ export async function checkServerHealth(
       .then(() => attempt(count + 1))
       .catch(() => ({ healthy: false }))
   }
-  const attempt = (count: number): Promise<ServerHealth> =>
-    createSdkForServer({
-      server,
+  const attempt = async (count: number): Promise<ServerHealth> => {
+    const current = await OpenCode.make({
+      baseUrl: server.url,
       fetch,
-      signal,
+      headers: server.password
+        ? {
+            Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
+          }
+        : undefined,
     })
+      .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?.())
 }
 

+ 40 - 0
packages/app/src/utils/server-protocol.test.ts

@@ -0,0 +1,40 @@
+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")
+  })
+})

+ 35 - 0
packages/app/src/utils/server-protocol.ts

@@ -0,0 +1,35 @@
+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"
+}

+ 21 - 0
packages/app/src/utils/server.ts

@@ -1,4 +1,5 @@
 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"
 
@@ -39,3 +40,23 @@ 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

BIN
packages/app/vendor/opencode-ai-client-1.17.13.tgz


+ 1 - 1
packages/core/package.json

@@ -77,7 +77,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.27",
+    "@ai-sdk/mistral": "3.0.51",
     "@ai-sdk/openai": "3.0.84",
     "@ai-sdk/openai-compatible": "2.0.41",
     "@ai-sdk/perplexity": "3.0.26",

+ 282 - 0
packages/core/test/provider-mistral.test.ts

@@ -0,0 +1,282 @@
+import { createMistral } from "@ai-sdk/mistral"
+import { expect, test } from "bun:test"
+
+test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
+  let body: Record<string, 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-large-latest",
+        object: "chat.completion",
+        choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
+        usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+      })
+    },
+    { preconnect: fetch.preconnect },
+  )
+  const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest")
+
+  await model.doGenerate({
+    prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
+    providerOptions: { mistral: { promptCacheKey: "session-123" } },
+  })
+
+  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 } },
+    },
+  ])
+})

+ 12 - 11
packages/desktop/src/main/server.ts

@@ -181,9 +181,9 @@ export async function spawnLocalServer(
 }
 
 export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
-  let healthUrl: URL
+  let healthUrls: URL[]
   try {
-    healthUrl = new URL("/global/health", url)
+    healthUrls = [new URL("/api/health", url), new URL("/global/health", url)]
   } catch {
     return false
   }
@@ -194,16 +194,17 @@ export async function checkHealth(url: string, password?: string | null): Promis
     headers.set("authorization", `Basic ${auth}`)
   }
 
-  try {
-    const res = await fetch(healthUrl, {
-      method: "GET",
-      headers,
-      signal: AbortSignal.timeout(3000),
-    })
-    return res.ok
-  } catch {
-    return false
+  for (const healthUrl of healthUrls) {
+    try {
+      const res = await fetch(healthUrl, {
+        method: "GET",
+        headers,
+        signal: AbortSignal.timeout(3000),
+      })
+      if (res.ok) return true
+    } catch {}
   }
+  return false
 }
 
 function createSidecarEnv(): Record<string, string> {

+ 1 - 0
packages/session-ui/package.json

@@ -39,6 +39,7 @@
   },
   "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:*",

+ 709 - 0
patches/@ai-sdk%2Fmistral@3.0.51.patch

@@ -0,0 +1,709 @@
+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;