@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -107,6 +107,51 @@ describe("testInferenceConnection", () => {
107
107
  expect(calls[0].body).not.toHaveProperty(OPENCODE_SESSION_HEADER);
108
108
  });
109
109
 
110
+ test("probes /responses for an OpenCode responses-only model", async () => {
111
+ const calls: ProbeCall[] = [];
112
+ const result = await testInferenceConnection(
113
+ {
114
+ provider: "opencode",
115
+ auth: { type: "none" },
116
+ baseUrl: "https://opencode.ai/zen/v1",
117
+ models: [{ id: "muse-spark-1.2-contributor-free" }],
118
+ },
119
+ stubFetch(200, calls),
120
+ );
121
+
122
+ expect(result).toMatchObject({
123
+ ok: true,
124
+ resolved_url: "https://opencode.ai/zen/v1/responses",
125
+ });
126
+ expect(calls[0].body).toMatchObject({
127
+ model: "muse-spark-1.2-contributor-free",
128
+ input: "ping",
129
+ max_output_tokens: 16,
130
+ });
131
+ expect(calls[0].headers[OPENCODE_SESSION_HEADER]).toBeTruthy();
132
+ });
133
+
134
+ test("keeps other OpenCode models on /chat/completions", async () => {
135
+ const calls: ProbeCall[] = [];
136
+ const result = await testInferenceConnection(
137
+ {
138
+ provider: "opencode",
139
+ auth: { type: "none" },
140
+ baseUrl: "https://opencode.ai/zen/v1",
141
+ models: [{ id: "mimo-v2.5-free" }],
142
+ },
143
+ stubFetch(200, calls),
144
+ );
145
+
146
+ expect(result?.resolved_url).toBe(
147
+ "https://opencode.ai/zen/v1/chat/completions",
148
+ );
149
+ expect(calls[0].body).toMatchObject({
150
+ model: "mimo-v2.5-free",
151
+ max_tokens: 1,
152
+ });
153
+ });
154
+
110
155
  test("does not send OpenCode headers to other providers", async () => {
111
156
  const calls: ProbeCall[] = [];
112
157
  await testInferenceConnection(CONNECTION, stubFetch(200, calls));
@@ -10,7 +10,10 @@
10
10
  import { z } from "zod";
11
11
 
12
12
  import { getLogger } from "../../util/logger.js";
13
- import { resolveOpenCodeRequestHeaders } from "../opencode/client.js";
13
+ import {
14
+ OPENCODE_RESPONSES_ONLY_MODELS,
15
+ resolveOpenCodeRequestHeaders,
16
+ } from "../opencode/client.js";
14
17
  import type { Auth, ConnectionModel } from "./auth.js";
15
18
  import { resolveAuth } from "./resolve-auth.js";
16
19
 
@@ -46,10 +49,12 @@ function hintForStatus(status: number): string {
46
49
  }
47
50
 
48
51
  /**
49
- * Probe a connection's custom endpoint with a minimal chat-completions
50
- * request (`max_tokens: 1`). Returns `null` when there is nothing to probe:
51
- * no custom base URL, no model id to send, or auth that cannot be resolved
52
- * (a missing credential surfaces through its own error path).
52
+ * Probe a connection's custom endpoint with a minimal request on the wire
53
+ * its first model is served over: chat completions (`max_tokens: 1`), or
54
+ * the Responses API for OpenCode's responses-only models, which answer
55
+ * `/chat/completions` with HTTP 500. Returns `null` when there is nothing
56
+ * to probe: no custom base URL, no model id to send, or auth that cannot be
57
+ * resolved (a missing credential surfaces through its own error path).
53
58
  */
54
59
  export async function testInferenceConnection(
55
60
  connection: {
@@ -78,7 +83,12 @@ export async function testInferenceConnection(
78
83
  const providerHeaders =
79
84
  connection.provider === "opencode" ? resolveOpenCodeRequestHeaders() : {};
80
85
 
81
- const url = `${connection.baseUrl.replace(/\/+$/, "")}/chat/completions`;
86
+ const useResponses =
87
+ connection.provider === "opencode" &&
88
+ OPENCODE_RESPONSES_ONLY_MODELS.has(model);
89
+ const url = `${connection.baseUrl.replace(/\/+$/, "")}${
90
+ useResponses ? "/responses" : "/chat/completions"
91
+ }`;
82
92
  try {
83
93
  const res = await fetchImpl(url, {
84
94
  method: "POST",
@@ -87,12 +97,17 @@ export async function testInferenceConnection(
87
97
  ...providerHeaders,
88
98
  "Content-Type": "application/json",
89
99
  },
90
- body: JSON.stringify({
91
- model,
92
- messages: [{ role: "user", content: "ping" }],
93
- max_tokens: 1,
94
- stream: false,
95
- }),
100
+ body: JSON.stringify(
101
+ useResponses
102
+ ? // The Responses API rejects output budgets below 16 tokens.
103
+ { model, input: "ping", max_output_tokens: 16, stream: false }
104
+ : {
105
+ model,
106
+ messages: [{ role: "user", content: "ping" }],
107
+ max_tokens: 1,
108
+ stream: false,
109
+ },
110
+ ),
96
111
  signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
97
112
  });
98
113
  void res.body?.cancel();
@@ -109,7 +109,9 @@ function mapResponsesReasoningEffort(
109
109
  return "none";
110
110
  }
111
111
  const clamped = clampReasoningEffort(raw, effortCeilingForModel(model));
112
- return supported ? snapReasoningEffortToSupported(clamped, supported) : clamped;
112
+ return supported
113
+ ? snapReasoningEffortToSupported(clamped, supported)
114
+ : clamped;
113
115
  }
114
116
 
115
117
  /** Values accepted by the Responses API `text.verbosity` parameter. */
@@ -280,7 +282,11 @@ export class OpenAIResponsesProvider implements Provider {
280
282
  const effort = configObj?.effort as string | undefined;
281
283
  const verbosity = configObj?.verbosity as string | undefined;
282
284
  const usageAttributionHeaders = configObj?.usageAttributionHeaders as
283
- Record<string, string> | undefined;
285
+ | Record<string, string>
286
+ | undefined;
287
+ const perRequestHeaders = configObj?.requestHeaders as
288
+ | Record<string, string>
289
+ | undefined;
284
290
  const disableCache = configObj?.disableCache === true;
285
291
  const disableTurnStartCache = configObj?.disableTurnStartCache === true;
286
292
  const promptCacheKey =
@@ -463,6 +469,7 @@ export class OpenAIResponsesProvider implements Provider {
463
469
  const requestHeaders = {
464
470
  ...this.requestHeaders,
465
471
  ...(usageAttributionHeaders ?? {}),
472
+ ...(perRequestHeaders ?? {}),
466
473
  };
467
474
  const stream = await responsesApi.create(
468
475
  { ...params, stream: true },
@@ -9,18 +9,72 @@ import {
9
9
  EMPTY_ASSISTANT_TURN_PLACEHOLDER,
10
10
  OpenAIChatCompletionsProvider,
11
11
  } from "../openai/chat-completions-provider.js";
12
+ import { OpenAIResponsesProvider } from "../openai/responses-provider.js";
13
+ import type { Message } from "../types.js";
12
14
  import {
13
15
  buildOpenCodeRequestHeaders,
14
16
  OPENCODE_GO_BASE_URL,
15
17
  OPENCODE_REQUEST_HEADER,
18
+ OPENCODE_RESPONSES_ONLY_MODELS,
16
19
  OPENCODE_SESSION_HEADER,
17
20
  OPENCODE_ZEN_BASE_URL,
18
21
  OpenCodeProvider,
22
+ OpenCodeResponsesProvider,
19
23
  resetOpenCodeFallbackSessionForTests,
20
24
  resolveOpenCodeBaseURL,
21
25
  resolveOpenCodeRequestHeaders,
22
26
  } from "./client.js";
23
27
 
28
+ const USER_TURN: Message[] = [
29
+ { role: "user", content: [{ type: "text", text: "question" }] },
30
+ ];
31
+
32
+ /** Stub both wires on an OpenCodeProvider and record which one was called. */
33
+ function stubTransports(provider: OpenCodeProvider) {
34
+ const chat: unknown[] = [];
35
+ const responses: Array<{ params: unknown; options: unknown }> = [];
36
+ (provider as unknown as { client: unknown }).client = {
37
+ chat: {
38
+ completions: {
39
+ create: async (params: unknown) => {
40
+ chat.push(params);
41
+ return {
42
+ async *[Symbol.asyncIterator]() {
43
+ yield {
44
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
45
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
46
+ };
47
+ },
48
+ };
49
+ },
50
+ },
51
+ },
52
+ };
53
+ const inner = (
54
+ provider as unknown as { getResponsesInner(): OpenCodeResponsesProvider }
55
+ ).getResponsesInner();
56
+ (inner as unknown as { client: unknown }).client = {
57
+ responses: {
58
+ create: async (params: unknown, options: unknown) => {
59
+ responses.push({ params, options });
60
+ return {
61
+ async *[Symbol.asyncIterator]() {
62
+ yield { type: "response.output_text.delta", delta: "ok" };
63
+ yield {
64
+ type: "response.completed",
65
+ response: {
66
+ status: "completed",
67
+ usage: { input_tokens: 2, output_tokens: 1 },
68
+ },
69
+ };
70
+ },
71
+ };
72
+ },
73
+ },
74
+ };
75
+ return { chat, responses };
76
+ }
77
+
24
78
  describe("resolveOpenCodeBaseURL", () => {
25
79
  test("defaults to OpenCode Zen", () => {
26
80
  expect(resolveOpenCodeBaseURL()).toBe(OPENCODE_ZEN_BASE_URL);
@@ -241,3 +295,116 @@ describe("OpenCodeProvider", () => {
241
295
  expect(assistantMsg.content).toBe(EMPTY_ASSISTANT_TURN_PLACEHOLDER);
242
296
  });
243
297
  });
298
+
299
+ describe("OPENCODE_RESPONSES_ONLY_MODELS", () => {
300
+ test("lists the verified responses-only models and nothing else", () => {
301
+ expect([...OPENCODE_RESPONSES_ONLY_MODELS].sort()).toEqual([
302
+ "muse-spark-1.2-contributor-free",
303
+ "muse-spark-1.3-contributor-free",
304
+ ]);
305
+ });
306
+ });
307
+
308
+ describe("OpenCodeResponsesProvider", () => {
309
+ test("is the Responses transport under the opencode name on OpenCode Zen", () => {
310
+ const provider = new OpenCodeResponsesProvider(
311
+ "sk-test",
312
+ "muse-spark-1.2-contributor-free",
313
+ );
314
+ expect(provider).toBeInstanceOf(OpenAIResponsesProvider);
315
+ expect(provider.name).toBe("opencode");
316
+ expect(
317
+ (provider as unknown as { client: { baseURL?: string } }).client.baseURL,
318
+ ).toBe(OPENCODE_ZEN_BASE_URL);
319
+ });
320
+
321
+ test("keeps a configured OpenCode Go origin", () => {
322
+ const provider = new OpenCodeResponsesProvider(
323
+ "sk-test",
324
+ "muse-spark-1.2-contributor-free",
325
+ { baseURL: OPENCODE_GO_BASE_URL },
326
+ );
327
+ expect(
328
+ (provider as unknown as { client: { baseURL?: string } }).client.baseURL,
329
+ ).toBe(OPENCODE_GO_BASE_URL);
330
+ });
331
+ });
332
+
333
+ describe("OpenCodeProvider transport selection", () => {
334
+ beforeEach(() => {
335
+ mockDeviceId = null;
336
+ resetOpenCodeFallbackSessionForTests();
337
+ });
338
+
339
+ test("sends a responses-only model through the Responses API with the caller's OpenCode headers", async () => {
340
+ const provider = new OpenCodeProvider(
341
+ "sk-test",
342
+ "muse-spark-1.2-contributor-free",
343
+ );
344
+ const { chat, responses } = stubTransports(provider);
345
+
346
+ const result = await provider.sendMessage(USER_TURN, {
347
+ config: {
348
+ requestHeaders: {
349
+ [OPENCODE_SESSION_HEADER]: "conv-xyz",
350
+ [OPENCODE_REQUEST_HEADER]: "req-123",
351
+ },
352
+ },
353
+ });
354
+
355
+ expect(chat).toHaveLength(0);
356
+ expect(responses).toHaveLength(1);
357
+ expect((responses[0]!.params as { model: string }).model).toBe(
358
+ "muse-spark-1.2-contributor-free",
359
+ );
360
+ const options = responses[0]!.options as {
361
+ headers?: Record<string, string>;
362
+ };
363
+ expect(options.headers?.[OPENCODE_SESSION_HEADER]).toBe("conv-xyz");
364
+ expect(options.headers?.[OPENCODE_REQUEST_HEADER]).toBe("req-123");
365
+ expect(options.headers).not.toHaveProperty("session_id");
366
+ expect(result.content).toEqual([{ type: "text", text: "ok" }]);
367
+ });
368
+
369
+ test("stamps the session backstop on a configless Responses call", async () => {
370
+ mockDeviceId = "device-abc";
371
+ const provider = new OpenCodeProvider(
372
+ "sk-test",
373
+ "muse-spark-1.2-contributor-free",
374
+ );
375
+ const { responses } = stubTransports(provider);
376
+
377
+ await provider.sendMessage(USER_TURN);
378
+
379
+ const options = responses[0]!.options as {
380
+ headers?: Record<string, string>;
381
+ };
382
+ expect(options.headers?.[OPENCODE_SESSION_HEADER]).toBe("device-abc");
383
+ expect(options.headers?.[OPENCODE_REQUEST_HEADER]).toBeTruthy();
384
+ });
385
+
386
+ test("keeps a chat-completions model on the chat wire", async () => {
387
+ const provider = new OpenCodeProvider("sk-test", "mimo-v2.5-free");
388
+ const { chat, responses } = stubTransports(provider);
389
+
390
+ await provider.sendMessage(USER_TURN);
391
+
392
+ expect(chat).toHaveLength(1);
393
+ expect(responses).toHaveLength(0);
394
+ });
395
+
396
+ test("a per-call model override picks the transport that model needs", async () => {
397
+ const provider = new OpenCodeProvider("sk-test", "mimo-v2.5-free");
398
+ const { chat, responses } = stubTransports(provider);
399
+
400
+ await provider.sendMessage(USER_TURN, {
401
+ config: { model: "muse-spark-1.2-contributor-free" },
402
+ });
403
+
404
+ expect(chat).toHaveLength(0);
405
+ expect(responses).toHaveLength(1);
406
+ expect((responses[0]!.params as { model: string }).model).toBe(
407
+ "muse-spark-1.2-contributor-free",
408
+ );
409
+ });
410
+ });
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { getExistingDeviceId } from "../../util/device-id.js";
4
4
  import { OpenAIChatCompletionsProvider } from "../openai/chat-completions-provider.js";
5
+ import { OpenAIResponsesProvider } from "../openai/responses-provider.js";
5
6
  import type {
6
7
  Message,
7
8
  ProviderResponse,
@@ -18,6 +19,18 @@ export interface OpenCodeProviderOptions {
18
19
  streamTimeoutMs?: number;
19
20
  }
20
21
 
22
+ /**
23
+ * OpenCode Zen models served only by the Responses API: Zen answers
24
+ * `/chat/completions` for them with HTTP 500 and completes the same request
25
+ * on `/responses`. OpenCode's own client picks the transport per model from
26
+ * its catalog's `api.endpoint`; the assistant has no such feed, so the
27
+ * verified ids live here. Everything else stays on chat completions.
28
+ */
29
+ export const OPENCODE_RESPONSES_ONLY_MODELS: ReadonlySet<string> = new Set([
30
+ "muse-spark-1.2-contributor-free",
31
+ "muse-spark-1.3-contributor-free",
32
+ ]);
33
+
21
34
  /**
22
35
  * Resolve the OpenCode chat-completions origin. A stored connection URL
23
36
  * wins; otherwise the request goes to OpenCode Zen.
@@ -82,7 +95,38 @@ export function resolveOpenCodeRequestHeaders(
82
95
  });
83
96
  }
84
97
 
98
+ /**
99
+ * Responses-API transport for OpenCode. Shares the chat client's origin
100
+ * resolution and provider name, so the same `x-opencode-session` /
101
+ * `x-opencode-request` headers reach its requests.
102
+ */
103
+ export class OpenCodeResponsesProvider extends OpenAIResponsesProvider {
104
+ constructor(
105
+ apiKey: string,
106
+ model: string,
107
+ options: OpenCodeProviderOptions = {},
108
+ ) {
109
+ super(apiKey, model, {
110
+ baseURL: resolveOpenCodeBaseURL(options.baseURL),
111
+ providerName: "opencode",
112
+ providerLabel: "OpenCode",
113
+ streamTimeoutMs: options.streamTimeoutMs,
114
+ });
115
+ }
116
+ }
117
+
118
+ /**
119
+ * OpenCode adapter. Chat completions is the default wire; models in
120
+ * {@link OPENCODE_RESPONSES_ONLY_MODELS} are delegated to an
121
+ * {@link OpenCodeResponsesProvider} on the same origin. The choice is made
122
+ * per send from the effective model, so a per-call model override lands on
123
+ * the transport that model needs.
124
+ */
85
125
  export class OpenCodeProvider extends OpenAIChatCompletionsProvider {
126
+ private readonly openCodeApiKey: string;
127
+ private readonly openCodeOptions: OpenCodeProviderOptions;
128
+ private responsesInner: OpenCodeResponsesProvider | undefined;
129
+
86
130
  constructor(
87
131
  apiKey: string,
88
132
  model: string,
@@ -96,20 +140,22 @@ export class OpenCodeProvider extends OpenAIChatCompletionsProvider {
96
140
  assistantReasoningField: "reasoning_content",
97
141
  omitToolChoiceWhenReasoning: true,
98
142
  });
143
+ this.openCodeApiKey = apiKey;
144
+ this.openCodeOptions = options;
99
145
  }
100
146
 
101
147
  /**
102
148
  * Transport-level backstop so a call that reaches this provider without
103
149
  * going through `RetryProvider` (or with no config at all) still carries
104
- * the session and request headers zen/go requires. Headers the caller
105
- * already resolved (conversation-derived) win.
150
+ * the session and request headers zen/go requires on either wire. Headers
151
+ * the caller already resolved (conversation-derived) win.
106
152
  */
107
153
  override async sendMessage(
108
154
  messages: Message[],
109
155
  options?: SendMessageOptions,
110
156
  ): Promise<ProviderResponse> {
111
157
  const config = options?.config ?? {};
112
- return super.sendMessage(messages, {
158
+ const stamped: SendMessageOptions = {
113
159
  ...options,
114
160
  config: {
115
161
  ...config,
@@ -118,6 +164,29 @@ export class OpenCodeProvider extends OpenAIChatCompletionsProvider {
118
164
  ...config.requestHeaders,
119
165
  },
120
166
  },
121
- });
167
+ };
168
+ if (
169
+ OPENCODE_RESPONSES_ONLY_MODELS.has(this.resolveEffectiveModel(config))
170
+ ) {
171
+ return this.getResponsesInner().sendMessage(messages, stamped);
172
+ }
173
+ return super.sendMessage(messages, stamped);
174
+ }
175
+
176
+ private resolveEffectiveModel(config: Record<string, unknown>): string {
177
+ const override =
178
+ typeof config.model === "string" && config.model.trim().length > 0
179
+ ? config.model.trim()
180
+ : undefined;
181
+ return override ?? this.defaultModel;
182
+ }
183
+
184
+ private getResponsesInner(): OpenCodeResponsesProvider {
185
+ this.responsesInner ??= new OpenCodeResponsesProvider(
186
+ this.openCodeApiKey,
187
+ this.defaultModel,
188
+ this.openCodeOptions,
189
+ );
190
+ return this.responsesInner;
122
191
  }
123
192
  }
@@ -161,6 +161,10 @@ In the CDP factory the bridge is the internal `"host-bridge"` candidate kind (`I
161
161
 
162
162
  **After the first successful CDP command**, the selected backend becomes **sticky** for the remainder of the tool invocation. Subsequent commands always route through the same backend so multi-command tool flows do not hop transports mid-step.
163
163
 
164
+ ### Browser CLI surface defaults
165
+
166
+ The browser execute and tab routes share `browser/virtual-desktop-target.ts`. Platform-hosted web guardian conversations use installed, enabled virtual desktop Chrome by default. The shared `isVirtualDesktopEnabled` gate requires both `IS_PLATFORM` and `IS_CONTAINERIZED` alongside the `assistant-desktop` flag. Native renderer turns also use the `web` transport, so the frozen turn `clientOs` excludes native apps from automatic streamed-browser selection. Native apps retain their existing backend selection and fallback behavior. Explicit desktop/backend/client targets and existing personal-browser sessions override the surface default. Disabled or uninstalled desktop support retains the existing browser path; selection never triggers installation.
167
+
164
168
  ### Per-tool `browser_mode` override
165
169
 
166
170
  All CDP-backed browser tools (`browser_navigate`, `browser_snapshot`, `browser_screenshot`, `browser_click`, `browser_type`, `browser_hover`, `browser_scroll`, `browser_press_key`, `browser_select_option`, `browser_wait_for`, `browser_extract`, `browser_fill_credential`, `browser_attach`, `browser_detach`, `browser_close`, `browser_status`) accept an optional `browser_mode` input parameter that overrides the automatic backend selection for that invocation.
@@ -15,6 +15,7 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
15
15
  let baseUrl: string;
16
16
  let restoreAuthEnv: () => void;
17
17
  const originalContainerized = process.env.IS_CONTAINERIZED;
18
+ const originalPlatform = process.env.IS_PLATFORM;
18
19
 
19
20
  beforeEach(async () => {
20
21
  restoreAuthEnv = requireHttpAuth();
@@ -22,6 +23,7 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
22
23
  server = new RuntimeHttpServer({ port, hostname: "127.0.0.1" });
23
24
  await server.start();
24
25
  baseUrl = `127.0.0.1:${server.actualPort}`;
26
+ process.env.IS_PLATFORM = "true";
25
27
  });
26
28
 
27
29
  afterEach(async () => {
@@ -32,6 +34,11 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
32
34
  } else {
33
35
  process.env.IS_CONTAINERIZED = originalContainerized;
34
36
  }
37
+ if (originalPlatform === undefined) {
38
+ delete process.env.IS_PLATFORM;
39
+ } else {
40
+ process.env.IS_PLATFORM = originalPlatform;
41
+ }
35
42
  setOverridesForTesting({});
36
43
  });
37
44
 
@@ -70,7 +77,9 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
70
77
  );
71
78
  const closed = await waitForClose(ws);
72
79
  expect(closed.code).toBe(4008);
73
- expect(closed.reason).toBe("Desktop is not available on this assistant");
80
+ expect(closed.reason).toBe(
81
+ "Virtual desktop is available only on enabled platform-hosted assistants",
82
+ );
74
83
  });
75
84
  for (const flag of [false, undefined]) {
76
85
  test(`refuses a containerized stream with a disabled or missing flag (${flag})`, async () => {
@@ -83,7 +92,41 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
83
92
  );
84
93
  const closed = await waitForClose(ws);
85
94
  expect(closed.code).toBe(4008);
86
- expect(closed.reason).toBe("Desktop is not available on this assistant");
95
+ expect(closed.reason).toBe(
96
+ "Virtual desktop is available only on enabled platform-hosted assistants",
97
+ );
87
98
  });
88
99
  }
89
100
  });
101
+
102
+ test("self-hosted containers cannot stream with the feature flag enabled", async () => {
103
+ const originalPlatform = process.env.IS_PLATFORM;
104
+ const originalContainerized = process.env.IS_CONTAINERIZED;
105
+ const restoreAuth = requireHttpAuth();
106
+ const server = new RuntimeHttpServer({ port: 0, hostname: "127.0.0.1" });
107
+ try {
108
+ await server.start();
109
+ process.env.IS_PLATFORM = "false";
110
+ process.env.IS_CONTAINERIZED = "true";
111
+ setOverridesForTesting({ "assistant-desktop": true });
112
+ const baseUrl = `127.0.0.1:${server.actualPort}`;
113
+ const ws = new WebSocket(
114
+ `ws://${baseUrl}/v1/desktop/stream?token=${encodeURIComponent(mintGatewayToken())}`,
115
+ );
116
+ expect((await waitForClose(ws)).code).toBe(4008);
117
+ } finally {
118
+ await server.stop();
119
+ restoreAuth();
120
+ if (originalPlatform === undefined) {
121
+ delete process.env.IS_PLATFORM;
122
+ } else {
123
+ process.env.IS_PLATFORM = originalPlatform;
124
+ }
125
+ if (originalContainerized === undefined) {
126
+ delete process.env.IS_CONTAINERIZED;
127
+ } else {
128
+ process.env.IS_CONTAINERIZED = originalContainerized;
129
+ }
130
+ setOverridesForTesting({});
131
+ }
132
+ });
@@ -32,12 +32,12 @@ import {
32
32
  } from "../daemon/daemon-readiness.js";
33
33
  import { processMessage } from "../daemon/process-message.js";
34
34
  import { makeAddrInUseError } from "../daemon/startup-error.js";
35
- import { isAssistantDesktopEnabled } from "../desktop/desktop-feature.js";
36
35
  import {
37
36
  DESKTOP_CLOSE,
38
37
  destroyDesktopSessionManager,
39
38
  } from "../desktop/desktop-session-manager.js";
40
39
  import { DesktopStreamBridge } from "../desktop/desktop-stream-bridge.js";
40
+ import { isVirtualDesktopEnabled } from "../desktop/virtual-desktop-feature.js";
41
41
  import {
42
42
  createLiveVoiceConnection,
43
43
  type LiveVoiceConnection,
@@ -252,9 +252,9 @@ type AllWebSocketData =
252
252
  | WatchStreamWebSocketData
253
253
  | DesktopStreamWebSocketData;
254
254
 
255
- function assistantDesktopEnabled(): boolean {
255
+ function virtualDesktopEnabled(): boolean {
256
256
  try {
257
- return isAssistantDesktopEnabled(getConfig());
257
+ return isVirtualDesktopEnabled(getConfig());
258
258
  } catch (err) {
259
259
  log.warn({ err }, "Failed to read config for desktop stream gate");
260
260
  return false;
@@ -453,15 +453,15 @@ export class RuntimeHttpServer {
453
453
  }
454
454
  if (data.wsType === "desktop-stream") {
455
455
  log.info("Desktop stream WebSocket opened");
456
- if (!assistantDesktopEnabled()) {
456
+ if (!virtualDesktopEnabled()) {
457
457
  ws.close(
458
458
  DESKTOP_CLOSE.unavailable,
459
- "Desktop is not available on this assistant",
459
+ "Virtual desktop is available only on enabled platform-hosted assistants",
460
460
  );
461
461
  return;
462
462
  }
463
463
  const bridge = new DesktopStreamBridge(ws, {
464
- isEnabled: assistantDesktopEnabled,
464
+ isEnabled: virtualDesktopEnabled,
465
465
  });
466
466
  data.bridge = bridge;
467
467
  void bridge.start();
@@ -48,7 +48,7 @@ mock.module("../../../security/oauth2.js", () => ({
48
48
 
49
49
  const actualClaudeOauth = await import("../../../acp/acp-claude-oauth.js");
50
50
  const { CLAUDE_MANUAL_REDIRECT_URI, CLAUDE_OAUTH_CONFIG } = actualClaudeOauth;
51
- const storeAcpClaudeTokenMock = mock(async (_token: string) => {});
51
+ const storeAcpClaudeTokenMock = mock(async (_tokens: unknown) => {});
52
52
  // The connect-status route reads token presence; mock it so the route test
53
53
  // doesn't reach real secure storage.
54
54
  const hasAcpClaudeTokenMock = mock(async () => false);
@@ -228,9 +228,12 @@ describe("loopback capture", () => {
228
228
  const status = await waitForStatus(state, "connected");
229
229
  expect(status).toEqual({ status: "connected" });
230
230
 
231
- // Access token persisted via storeAcpClaudeToken.
232
231
  expect(storeAcpClaudeTokenMock).toHaveBeenCalledTimes(1);
233
- expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith("sk-ant-oat-access");
232
+ expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith({
233
+ accessToken: "sk-ant-oat-access",
234
+ refreshToken: "refresh-xyz",
235
+ expiresIn: 3600,
236
+ });
234
237
  });
235
238
 
236
239
  test("flips status to error when the exchange fails", async () => {
@@ -339,7 +342,11 @@ describe("acp_claude_auth_exchange", () => {
339
342
  expect(call[1]).toBe("auth-code-123");
340
343
  expect(call[2]).toBe(CLAUDE_MANUAL_REDIRECT_URI);
341
344
  expect(call[3]).toBeTruthy(); // PKCE verifier from the start call
342
- expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith("sk-ant-oat-manual");
345
+ expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith({
346
+ accessToken: "sk-ant-oat-manual",
347
+ refreshToken: "refresh-manual",
348
+ expiresIn: 3600,
349
+ });
343
350
 
344
351
  // The pending entry is consumed — a second exchange fails.
345
352
  await expect(
@@ -360,7 +367,11 @@ describe("acp_claude_auth_exchange", () => {
360
367
  string,
361
368
  ];
362
369
  expect(call[1]).toBe("raw-code-xyz");
363
- expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith("sk-ant-oat-manual");
370
+ expect(storeAcpClaudeTokenMock).toHaveBeenCalledWith({
371
+ accessToken: "sk-ant-oat-manual",
372
+ refreshToken: "refresh-manual",
373
+ expiresIn: 3600,
374
+ });
364
375
  });
365
376
 
366
377
  test("malformed paste (no `#`, no state) is rejected", async () => {