@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4

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 (162) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/acp/session-snapshot.ts +260 -0
  21. package/src/apps/app-store.ts +6 -5
  22. package/src/browser/operations.ts +2 -1
  23. package/src/browser/types.ts +7 -0
  24. package/src/browser/virtual-desktop-target.ts +34 -0
  25. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  26. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  28. package/src/calls/progress-narration.ts +15 -2
  29. package/src/calls/voice-control-protocol.ts +34 -4
  30. package/src/calls/voice-session-bridge.ts +23 -0
  31. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  32. package/src/cli/commands/browser.help.ts +43 -0
  33. package/src/cli/commands/browser.ts +52 -8
  34. package/src/cli/commands/plugins.ts +16 -5
  35. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  36. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  37. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  38. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  39. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  40. package/src/cli/lib/bundled-marketplace.json +13 -0
  41. package/src/cli/lib/install-from-github.ts +17 -18
  42. package/src/cli/lib/install-from-platform.ts +14 -9
  43. package/src/cli/lib/list-installed-plugins.ts +21 -35
  44. package/src/cli/lib/plugin-details.ts +21 -8
  45. package/src/cli/lib/uninstall-plugin.ts +59 -1
  46. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  47. package/src/config/bundled-skills/acp/SKILL.md +1 -1
  48. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  49. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  50. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  51. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  52. package/src/config/feature-flag-registry.json +2 -2
  53. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  54. package/src/config/schemas/mcp.ts +9 -7
  55. package/src/config/schemas/monitoring.ts +9 -0
  56. package/src/config/schemas/voice.ts +2 -2
  57. package/src/config/skills.ts +13 -21
  58. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  59. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  60. package/src/daemon/conversation-client-surface.ts +26 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  62. package/src/daemon/conversation-tool-setup.ts +31 -29
  63. package/src/daemon/host-cu-proxy.ts +51 -26
  64. package/src/daemon/host-proxy-preactivation.ts +2 -0
  65. package/src/daemon/mcp-reload-service.ts +7 -0
  66. package/src/daemon/message-types/sync.ts +1 -0
  67. package/src/daemon/providers-setup.ts +4 -0
  68. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  69. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  70. package/src/desktop/desktop-automation-lease.ts +270 -0
  71. package/src/desktop/desktop-browser-client.test.ts +413 -0
  72. package/src/desktop/desktop-browser-client.ts +430 -0
  73. package/src/desktop/desktop-browser-cursor.ts +40 -0
  74. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  75. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  76. package/src/desktop/desktop-browser-operations.ts +130 -0
  77. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  78. package/src/desktop/desktop-chrome-session.ts +58 -8
  79. package/src/desktop/desktop-dependencies.test.ts +29 -0
  80. package/src/desktop/desktop-dependencies.ts +26 -5
  81. package/src/desktop/desktop-display.ts +9 -0
  82. package/src/desktop/desktop-panel-config.ts +14 -1
  83. package/src/desktop/desktop-session-manager.test.ts +86 -3
  84. package/src/desktop/desktop-session-manager.ts +144 -24
  85. package/src/desktop/desktop-stream-bridge.ts +1 -1
  86. package/src/desktop/virtual-desktop-feature.ts +18 -0
  87. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  88. package/src/i18n/__tests__/i18n.test.ts +9 -5
  89. package/src/i18n/messages.ts +13 -4
  90. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  91. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  92. package/src/ipc/assistant-server.ts +15 -1
  93. package/src/ipc/cli-client.ts +13 -5
  94. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  95. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  97. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  98. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  99. package/src/live-voice/live-voice-metrics.ts +9 -0
  100. package/src/live-voice/live-voice-session.ts +3 -0
  101. package/src/live-voice/protocol.ts +10 -1
  102. package/src/live-voice/session-controls.ts +28 -0
  103. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  104. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  105. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  106. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  107. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  108. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  109. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  110. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  111. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  112. package/src/mcp/client.ts +64 -16
  113. package/src/mcp/credential-target.ts +105 -0
  114. package/src/mcp/effective-config.ts +42 -15
  115. package/src/mcp/manager.ts +61 -8
  116. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  117. package/src/mcp/mcp-auth-state.ts +38 -6
  118. package/src/mcp/mcp-oauth-provider.ts +101 -33
  119. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  120. package/src/monitoring/mount-watch.ts +348 -0
  121. package/src/monitoring/worker.ts +9 -0
  122. package/src/notifications/__tests__/decision-engine.test.ts +175 -0
  123. package/src/notifications/decision-engine.ts +43 -8
  124. package/src/oauth/seed-providers.ts +11 -0
  125. package/src/onboarding/checkin-event.ts +2 -1
  126. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  127. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  128. package/src/plugins/external-plugin-loader.ts +49 -86
  129. package/src/plugins/installed-plugin-dirs.ts +6 -4
  130. package/src/plugins/mcp-servers.ts +9 -15
  131. package/src/plugins/mtime-cache.ts +2 -1
  132. package/src/plugins/user-loader.ts +2 -1
  133. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  134. package/src/providers/inference/endpoint-probe.ts +27 -12
  135. package/src/providers/openai/responses-provider.ts +9 -2
  136. package/src/providers/opencode/client.test.ts +167 -0
  137. package/src/providers/opencode/client.ts +73 -4
  138. package/src/runtime/AGENTS.md +4 -0
  139. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  140. package/src/runtime/http-server.ts +6 -6
  141. package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  143. package/src/runtime/routes/acp-routes.ts +17 -161
  144. package/src/runtime/routes/browser-context.ts +39 -0
  145. package/src/runtime/routes/browser-routes.ts +16 -47
  146. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  147. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  148. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  149. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  150. package/src/runtime/routes/plugins-routes.ts +14 -1
  151. package/src/runtime/sync/resource-sync-events.ts +4 -0
  152. package/src/tools/acp/status.test.ts +276 -21
  153. package/src/tools/acp/status.ts +98 -32
  154. package/src/tools/browser/browser-execution.ts +44 -13
  155. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  156. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  157. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  158. package/src/tools/computer-use/definitions.ts +2 -3
  159. package/src/util/abort-reasons.ts +12 -1
  160. package/src/util/plugin-manifest.ts +203 -0
  161. package/src/desktop/desktop-feature.test.ts +0 -18
  162. package/src/desktop/desktop-feature.ts +0 -19
@@ -17,7 +17,12 @@ import { z } from "zod";
17
17
  import type { McpServerConfig } from "../../config/schemas/mcp.js";
18
18
  import { estimateToolDefinitionTokens } from "../../context/token-estimator.js";
19
19
  import { reloadMcpServers } from "../../daemon/mcp-reload-service.js";
20
- import { McpClient } from "../../mcp/client.js";
20
+ import {
21
+ mcpOAuthCredentialKey,
22
+ resolveMcpOAuthCredentialTarget,
23
+ workspaceMcpOAuthCredentialTarget,
24
+ } from "../../mcp/credential-target.js";
25
+ import { readEffectiveMcpConfig } from "../../mcp/effective-config.js";
21
26
  import { getMcpServerManager } from "../../mcp/manager.js";
22
27
  import { orchestrateMcpOAuthConnect } from "../../mcp/mcp-auth-orchestrator.js";
23
28
  import { getMcpAuthState } from "../../mcp/mcp-auth-state.js";
@@ -74,9 +79,7 @@ function persistWorkspaceMcpConfig(config: {
74
79
  try {
75
80
  saveWorkspaceMcpConfig(config);
76
81
  } catch (err) {
77
- throw new InternalError(
78
- err instanceof Error ? err.message : String(err),
79
- );
82
+ throw new InternalError(err instanceof Error ? err.message : String(err));
80
83
  }
81
84
  }
82
85
 
@@ -91,8 +94,9 @@ async function handleMcpAuthStart({
91
94
  }> {
92
95
  const { serverId } = parseBody(McpServerIdParams, body);
93
96
 
94
- const servers = loadWorkspaceMcpConfig().servers;
95
- const serverConfig = servers[serverId];
97
+ const serverConfig = readEffectiveMcpConfig(loadWorkspaceMcpConfig()).servers[
98
+ serverId
99
+ ];
96
100
 
97
101
  if (!serverConfig) {
98
102
  throw new BadRequestError(`MCP server "${serverId}" not configured`);
@@ -105,10 +109,29 @@ async function handleMcpAuthStart({
105
109
  );
106
110
  }
107
111
 
112
+ const credentialTarget = resolveMcpOAuthCredentialTarget(
113
+ serverId,
114
+ serverConfig,
115
+ );
116
+ if (!credentialTarget) {
117
+ throw new BadRequestError(
118
+ `OAuth only supported for sse/streamable-http transports (server "${serverId}" uses ${transport.type})`,
119
+ );
120
+ }
121
+
122
+ try {
123
+ mcpOAuthCredentialKey(credentialTarget, "tokens");
124
+ } catch {
125
+ throw new BadRequestError(
126
+ `MCP server "${serverId}" has an invalid transport URL`,
127
+ );
128
+ }
129
+
108
130
  let result: { auth_url: string; already_authenticated?: boolean };
109
131
  try {
110
132
  result = await orchestrateMcpOAuthConnect({
111
133
  serverId,
134
+ credentialTarget,
112
135
  transport: {
113
136
  url: transport.url,
114
137
  type: transport.type,
@@ -135,7 +158,19 @@ function handleMcpAuthStatus({
135
158
  | { status: "complete" }
136
159
  | { status: "error"; error: string } {
137
160
  const { serverId } = pathParams as { serverId: string };
138
- const state = getMcpAuthState(serverId);
161
+ const serverConfig = readEffectiveMcpConfig(loadWorkspaceMcpConfig()).servers[
162
+ serverId
163
+ ];
164
+ const target = serverConfig
165
+ ? resolveMcpOAuthCredentialTarget(serverId, serverConfig)
166
+ : null;
167
+ let targetKey: string | undefined;
168
+ try {
169
+ targetKey = target ? mcpOAuthCredentialKey(target, "tokens") : undefined;
170
+ } catch {
171
+ targetKey = undefined;
172
+ }
173
+ const state = targetKey ? getMcpAuthState(serverId, targetKey) : null;
139
174
 
140
175
  if (state === null) {
141
176
  throw new NotFoundError(`No active OAuth flow for server "${serverId}"`);
@@ -170,53 +205,6 @@ function handleMcpReload(_args: { body?: Record<string, unknown> }): {
170
205
  return { ok: true };
171
206
  }
172
207
 
173
- // ---------------------------------------------------------------------------
174
- // Health check helper
175
- // ---------------------------------------------------------------------------
176
-
177
- const HEALTH_CHECK_TIMEOUT_MS = 10_000;
178
-
179
- async function checkMachineReadableHealth(
180
- serverId: string,
181
- config: McpServerConfig,
182
- timeoutMs = HEALTH_CHECK_TIMEOUT_MS,
183
- ): Promise<string> {
184
- const client = new McpClient(serverId);
185
- try {
186
- await Promise.race([
187
- client.connect(config.transport),
188
- new Promise<never>((_, reject) => {
189
- const t = setTimeout(() => reject(new Error("timeout")), timeoutMs);
190
- if (typeof t === "object" && "unref" in t) {
191
- t.unref();
192
- }
193
- }),
194
- ]);
195
-
196
- if (client.isConnected) {
197
- await client.disconnect();
198
- return "connected";
199
- }
200
-
201
- const err = client.lastError;
202
- if (err) {
203
- if (err.message.includes("timeout")) {
204
- return "error";
205
- }
206
- return "error";
207
- }
208
-
209
- return "needs-auth";
210
- } catch {
211
- try {
212
- await client.disconnect();
213
- } catch {
214
- /* ignore */
215
- }
216
- return "error";
217
- }
218
- }
219
-
220
208
  // ---------------------------------------------------------------------------
221
209
  // List
222
210
  // ---------------------------------------------------------------------------
@@ -271,12 +259,19 @@ async function handleMcpList(_args: {
271
259
  const configEntries = (
272
260
  Object.entries(servers) as [string, McpServerConfig][]
273
261
  ).filter(([, config]) => config && typeof config === "object");
262
+ const manager = getMcpServerManager();
274
263
 
275
264
  const workspaceEntries: McpServerEntry[] = await Promise.all(
276
265
  configEntries.map(async ([id, config]) => {
277
- const status = await checkMachineReadableHealth(id, config);
266
+ const runtimeState = manager.getServerState(id, "workspace");
267
+ const status =
268
+ runtimeState === "connected" || runtimeState === "needs-auth"
269
+ ? runtimeState
270
+ : "error";
278
271
  const hasOAuth =
279
- config.transport.type !== "stdio" ? await hasMcpOAuthTokens(id) : false;
272
+ config.transport.type !== "stdio"
273
+ ? await hasMcpOAuthTokens(workspaceMcpOAuthCredentialTarget(id))
274
+ : false;
280
275
 
281
276
  // Check credential store for stored static auth headers
282
277
  const storedHeaders = await getMcpHeaders(id);
@@ -316,21 +311,23 @@ async function handleMcpList(_args: {
316
311
  );
317
312
 
318
313
  return {
319
- servers: [...workspaceEntries, ...listPluginServerEntries(configEntries)],
314
+ servers: [
315
+ ...workspaceEntries,
316
+ ...(await listPluginServerEntries(configEntries)),
317
+ ],
320
318
  };
321
319
  }
322
320
 
323
321
  /**
324
322
  * Project plugin-declared servers onto listing entries.
325
323
  *
326
- * Nothing here touches the credential store or the network: a plugin
327
- * server carries no auth state the assistant owns, and its status comes
328
- * from the manager's in-memory client rather than a probe (see
329
- * {@link PLUGIN_SERVER_STATUS}).
324
+ * OAuth presence is read from the plugin's scoped credential identity. Static
325
+ * workspace headers are never consulted, no network probe runs, and status
326
+ * comes from the manager's in-memory client (see {@link PLUGIN_SERVER_STATUS}).
330
327
  */
331
- function listPluginServerEntries(
328
+ async function listPluginServerEntries(
332
329
  configEntries: [string, McpServerConfig][],
333
- ): McpServerEntry[] {
330
+ ): Promise<McpServerEntry[]> {
334
331
  const { servers, issues } = readPluginMcpServers();
335
332
  for (const issue of issues) {
336
333
  log.warn(
@@ -349,36 +346,51 @@ function listPluginServerEntries(
349
346
  const workspaceIds = new Set(configEntries.map(([id]) => id));
350
347
  const manager = getMcpServerManager();
351
348
 
352
- return servers
353
- .filter((server) => {
354
- if (!workspaceIds.has(server.id)) {
355
- return true;
356
- }
357
- log.warn(
358
- { plugin: server.pluginName, serverId: server.id },
359
- "Plugin MCP server shadowed by a workspace server of the same id; skipping",
360
- );
361
- return false;
362
- })
363
- .map((server) => {
364
- const { headers: _stripped, ...safeTransport } = server.config
365
- .transport as Record<string, unknown>;
366
- return {
367
- id: server.id,
368
- status: manager.getClient(server.id)?.isConnected
369
- ? "connected"
370
- : PLUGIN_SERVER_STATUS,
371
- transport: safeTransport as McpServerEntry["transport"],
372
- // A plugin server has no assistant-owned credentials. Resolving
373
- // these against `mcp:<id>:*` would report, and could disclose, a
374
- // workspace credential that happens to share the id.
375
- hasOAuth: false,
376
- hasStaticAuth: false,
377
- authType: "none" as const,
378
- source: "plugin" as const,
379
- pluginName: server.pluginName,
380
- };
381
- });
349
+ return Promise.all(
350
+ servers
351
+ .filter((server) => {
352
+ if (!workspaceIds.has(server.id)) {
353
+ return true;
354
+ }
355
+ log.warn(
356
+ { plugin: server.pluginName, serverId: server.id },
357
+ "Plugin MCP server shadowed by a workspace server of the same id; skipping",
358
+ );
359
+ return false;
360
+ })
361
+ .map(async (server) => {
362
+ const { headers: _stripped, ...safeTransport } = server.config
363
+ .transport as Record<string, unknown>;
364
+ let hasOAuth = false;
365
+ const credentialTarget = resolveMcpOAuthCredentialTarget(
366
+ server.id,
367
+ server.config,
368
+ );
369
+ if (credentialTarget) {
370
+ try {
371
+ hasOAuth = await hasMcpOAuthTokens(credentialTarget);
372
+ } catch (err) {
373
+ log.warn(
374
+ { err, plugin: server.pluginName, serverId: server.id },
375
+ "Plugin MCP OAuth credential identity could not be resolved",
376
+ );
377
+ }
378
+ }
379
+ return {
380
+ id: server.id,
381
+ status:
382
+ manager.getServerState(server.id, "plugin") === "connected"
383
+ ? "connected"
384
+ : PLUGIN_SERVER_STATUS,
385
+ transport: safeTransport as McpServerEntry["transport"],
386
+ hasOAuth,
387
+ hasStaticAuth: false,
388
+ authType: "none" as const,
389
+ source: "plugin" as const,
390
+ pluginName: server.pluginName,
391
+ };
392
+ }),
393
+ );
382
394
  }
383
395
 
384
396
  // ---------------------------------------------------------------------------
@@ -687,7 +699,7 @@ export const ROUTES: RouteDefinition[] = [
687
699
  },
688
700
  summary: "List MCP servers with health status",
689
701
  description:
690
- "Returns configured MCP servers with live health-check results (connected, needs auth, error).",
702
+ "Returns configured MCP servers with recorded runtime status without opening new connections.",
691
703
  tags: ["internal"],
692
704
  responseBody: z.object({
693
705
  servers: z.array(
@@ -80,6 +80,7 @@ import {
80
80
  PluginDirectoryNotFoundError,
81
81
  } from "../../cli/lib/toggle-plugin.js";
82
82
  import {
83
+ PLUGIN_UNINSTALL_WARNING_KEYS,
83
84
  PluginNotInstalledError,
84
85
  uninstallPlugin,
85
86
  } from "../../cli/lib/uninstall-plugin.js";
@@ -255,6 +256,14 @@ const pluginUninstallResponseSchema = z.object({
255
256
  .describe(
256
257
  "Absolute path that was removed on the assistant host. Useful for audit logs and confirmation toasts.",
257
258
  ),
259
+ warnings: z
260
+ .array(
261
+ z.literal(PLUGIN_UNINSTALL_WARNING_KEYS.MCP_OAUTH_CREDENTIALS_UNCHECKED),
262
+ )
263
+ .optional()
264
+ .describe(
265
+ "Stable keys for non-fatal cleanup limitations that should be localized at the presentation edge.",
266
+ ),
258
267
  });
259
268
 
260
269
  const pluginDetailsResponseSchema = z.object({
@@ -1134,7 +1143,11 @@ async function handleUninstallPlugin({
1134
1143
  const result = await uninstallPlugin({ name: rawName });
1135
1144
  await reconcilePluginSourcesNow();
1136
1145
  publishPluginsChanged(getOriginClientId(headers));
1137
- return { name: result.name, target: result.target };
1146
+ return {
1147
+ name: result.name,
1148
+ target: result.target,
1149
+ ...(result.warnings && { warnings: result.warnings }),
1150
+ };
1138
1151
  } catch (err) {
1139
1152
  if (err instanceof InvalidPluginNameError) {
1140
1153
  throw new BadRequestError(err.message);
@@ -152,6 +152,10 @@ export function publishPluginsChanged(originClientId?: string): void {
152
152
  void publishSyncInvalidation([SYNC_TAGS.pluginsList], originClientId);
153
153
  }
154
154
 
155
+ export function publishMcpChanged(): void {
156
+ void publishSyncInvalidation([SYNC_TAGS.mcpList]);
157
+ }
158
+
155
159
  /**
156
160
  * Invalidate the activation-checklist progress resource on every client.
157
161
  *
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Tests for the `acp_status` tool's projection of session state.
2
+ * Tests for the `acp_status` tool's live and persisted session projection.
3
3
  *
4
- * The state the manager tracks feeds three surfaces with different budgets:
5
- * the HTTP route, the SSE picker, and this tool, whose output is spent on
6
- * LLM context. Only the fields an agent can act on belong here.
4
+ * The session manager owns live process state. Completed sessions move to
5
+ * durable history, where the status tool must keep them discoverable for
6
+ * follow-up work without making internal lifecycle guards treat them as live.
7
7
  */
8
8
 
9
- import { describe, expect, mock, test } from "bun:test";
9
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
10
 
11
11
  import type { AcpSessionState } from "../../acp/types.js";
12
12
  import type { ToolContext } from "../types.js";
@@ -26,24 +26,49 @@ const RUNNING_STATE: AcpSessionState = {
26
26
  ],
27
27
  };
28
28
 
29
- let statusResult: AcpSessionState | AcpSessionState[] = RUNNING_STATE;
29
+ let liveStates: AcpSessionState[] = [];
30
+ let fakeStoredCredential: string | undefined;
30
31
 
31
32
  const realAcpModule = await import("../../acp/index.js");
32
33
  mock.module("../../acp/index.js", () => ({
33
34
  ...realAcpModule,
34
- getAcpSessionManager: () => ({ getStatus: () => statusResult }),
35
+ getAcpSessionManager: () => ({
36
+ getStatus: () => liveStates,
37
+ getBufferedUpdates: () => [],
38
+ }),
35
39
  }));
36
40
 
41
+ const realClaudeOauth = await import("../../acp/acp-claude-oauth.js");
42
+ mock.module("../../acp/acp-claude-oauth.js", () => ({
43
+ ...realClaudeOauth,
44
+ storedClaudeTokenDigest: async () => fakeStoredCredential,
45
+ }));
46
+
47
+ import {
48
+ clearHistory,
49
+ insertHistoryRow,
50
+ } from "../../acp/__tests__/helpers/acp-history-db.js";
51
+ import { claudeTokenDigest } from "../../acp/acp-auth-marker-store.js";
52
+ import { initializeDb } from "../../persistence/db-init.js";
53
+
37
54
  const { executeAcpStatus } = await import("./status.js");
38
55
 
56
+ await initializeDb();
57
+
39
58
  const context = {
40
59
  conversationId: "conv-1",
41
60
  workingDir: "/tmp",
42
61
  } as unknown as ToolContext;
43
62
 
63
+ beforeEach(() => {
64
+ liveStates = [];
65
+ fakeStoredCredential = undefined;
66
+ clearHistory();
67
+ });
68
+
44
69
  describe("executeAcpStatus", () => {
45
- test("a single session reports its model but not the picker", async () => {
46
- statusResult = RUNNING_STATE;
70
+ test("a live session reports its model but not the picker", async () => {
71
+ liveStates = [RUNNING_STATE];
47
72
 
48
73
  const result = await executeAcpStatus({ acp_session_id: "acp-1" }, context);
49
74
 
@@ -62,33 +87,263 @@ describe("executeAcpStatus", () => {
62
87
  expect(result.content).not.toContain("Most capable");
63
88
  });
64
89
 
65
- test("the listing drops the picker from every entry", async () => {
66
- statusResult = [
67
- RUNNING_STATE,
68
- { ...RUNNING_STATE, id: "acp-2", model: "sonnet" },
69
- ];
90
+ test("a cleanly completed resumable session is returned as idle", async () => {
91
+ insertHistoryRow({
92
+ id: "completed-1",
93
+ acpSessionId: "proto-completed-1",
94
+ startedAt: 2000,
95
+ completedAt: 3000,
96
+ status: "completed",
97
+ stopReason: "end_turn",
98
+ cwd: "/tmp/project",
99
+ task: "implement the parser",
100
+ usedTokens: 1200,
101
+ contextSize: 10000,
102
+ });
70
103
 
71
- const result = await executeAcpStatus({}, context);
104
+ const result = await executeAcpStatus(
105
+ { acp_session_id: "completed-1" },
106
+ context,
107
+ );
108
+
109
+ expect(result.isError).toBe(false);
110
+ expect(JSON.parse(result.content)).toEqual({
111
+ id: "completed-1",
112
+ agentId: "claude",
113
+ acpSessionId: "proto-completed-1",
114
+ parentConversationId: "conv-1",
115
+ status: "idle",
116
+ startedAt: 2000,
117
+ completedAt: 3000,
118
+ stopReason: "end_turn",
119
+ task: "implement the parser",
120
+ latestUsage: {
121
+ usedTokens: 1200,
122
+ contextSize: 10000,
123
+ },
124
+ resumable: true,
125
+ lastRunStatus: "completed",
126
+ });
127
+ });
128
+
129
+ test("failed and cancelled history rows keep their terminal status", async () => {
130
+ insertHistoryRow({
131
+ id: "failed-1",
132
+ status: "failed",
133
+ error: "adapter failed",
134
+ cwd: "/tmp/project",
135
+ });
136
+ insertHistoryRow({
137
+ id: "cancelled-1",
138
+ status: "cancelled",
139
+ stopReason: "cancelled",
140
+ cwd: "/tmp/project",
141
+ });
72
142
 
143
+ const failed = await executeAcpStatus(
144
+ { acp_session_id: "failed-1" },
145
+ context,
146
+ );
147
+ const cancelled = await executeAcpStatus(
148
+ { acp_session_id: "cancelled-1" },
149
+ context,
150
+ );
151
+
152
+ expect(JSON.parse(failed.content)).toMatchObject({
153
+ status: "failed",
154
+ resumable: true,
155
+ lastRunStatus: "failed",
156
+ error: "adapter failed",
157
+ });
158
+ expect(JSON.parse(cancelled.content)).toMatchObject({
159
+ status: "cancelled",
160
+ resumable: true,
161
+ lastRunStatus: "cancelled",
162
+ });
163
+ });
164
+
165
+ test("a completed legacy row without cwd stays terminal", async () => {
166
+ insertHistoryRow({ id: "legacy-1", status: "completed", cwd: null });
167
+
168
+ const result = await executeAcpStatus(
169
+ { acp_session_id: "legacy-1" },
170
+ context,
171
+ );
172
+
173
+ expect(JSON.parse(result.content)).toMatchObject({
174
+ status: "completed",
175
+ resumable: false,
176
+ lastRunStatus: "completed",
177
+ });
178
+ });
179
+
180
+ test("a completed run stopped by cancellation stays terminal", async () => {
181
+ insertHistoryRow({
182
+ id: "partial-1",
183
+ status: "completed",
184
+ stopReason: "cancelled",
185
+ cwd: "/tmp/project",
186
+ });
187
+
188
+ const result = await executeAcpStatus(
189
+ { acp_session_id: "partial-1" },
190
+ context,
191
+ );
192
+
193
+ expect(JSON.parse(result.content)).toMatchObject({
194
+ status: "completed",
195
+ resumable: true,
196
+ lastRunStatus: "completed",
197
+ stopReason: "cancelled",
198
+ });
199
+ });
200
+
201
+ test("the listing includes live and persisted idle sessions", async () => {
202
+ liveStates = [RUNNING_STATE];
203
+ insertHistoryRow({
204
+ id: "idle-1",
205
+ startedAt: 2000,
206
+ status: "completed",
207
+ cwd: "/tmp/project",
208
+ });
209
+
210
+ const result = await executeAcpStatus({}, context);
73
211
  const payload = JSON.parse(result.content) as Array<
74
212
  Record<string, unknown>
75
213
  >;
76
- expect(payload).toHaveLength(2);
77
- expect(payload.map((entry) => entry.model)).toEqual([
78
- "claude-opus-4-5",
79
- "sonnet",
214
+
215
+ expect(payload.map((entry) => [entry.id, entry.status])).toEqual([
216
+ ["idle-1", "idle"],
217
+ ["acp-1", "running"],
80
218
  ]);
81
219
  for (const entry of payload) {
82
220
  expect(entry).not.toHaveProperty("availableModels");
221
+ expect(entry).not.toHaveProperty("eventLog");
222
+ expect(entry).not.toHaveProperty("cwd");
223
+ expect(entry).not.toHaveProperty("source");
224
+ expect(entry).not.toHaveProperty("authErrorCredential");
83
225
  }
84
226
  });
85
227
 
86
- test("an empty listing says so rather than rendering an empty array", async () => {
87
- statusResult = [];
228
+ test("live state wins over history for the same id", async () => {
229
+ liveStates = [RUNNING_STATE];
230
+ insertHistoryRow({
231
+ id: RUNNING_STATE.id,
232
+ status: "completed",
233
+ cwd: "/tmp/project",
234
+ });
88
235
 
236
+ const result = await executeAcpStatus(
237
+ { acp_session_id: RUNNING_STATE.id },
238
+ context,
239
+ );
240
+
241
+ expect(JSON.parse(result.content)).toMatchObject({
242
+ id: RUNNING_STATE.id,
243
+ status: "running",
244
+ agentId: RUNNING_STATE.agentId,
245
+ });
246
+ });
247
+
248
+ test("a truly unknown id keeps the not-found error", async () => {
249
+ const result = await executeAcpStatus(
250
+ { acp_session_id: "missing-1" },
251
+ context,
252
+ );
253
+
254
+ expect(result).toEqual({
255
+ content: 'ACP session "missing-1" not found',
256
+ isError: true,
257
+ });
258
+ });
259
+
260
+ test("an empty listing says so rather than rendering an empty array", async () => {
89
261
  const result = await executeAcpStatus({}, context);
90
262
 
91
263
  expect(result.content).toBe("No ACP sessions found.");
92
264
  expect(result.isError).toBe(false);
93
265
  });
266
+
267
+ test("withholds a persisted auth marker after the credential is replaced", async () => {
268
+ const refused = claudeTokenDigest("sk-ant-oat-refused");
269
+ insertHistoryRow({
270
+ id: "auth-failed-1",
271
+ status: "failed",
272
+ error: "authentication required",
273
+ cwd: "/tmp/project",
274
+ authErrorCode: "acp_claude_auth_required",
275
+ authErrorCredential: refused,
276
+ });
277
+ fakeStoredCredential = claudeTokenDigest("sk-ant-oat-replacement");
278
+
279
+ const result = await executeAcpStatus(
280
+ { acp_session_id: "auth-failed-1" },
281
+ context,
282
+ );
283
+
284
+ expect(JSON.parse(result.content)).toMatchObject({
285
+ id: "auth-failed-1",
286
+ status: "failed",
287
+ lastRunStatus: "failed",
288
+ });
289
+ expect(JSON.parse(result.content).authErrorCode).toBeUndefined();
290
+ expect(result.content).not.toContain("authErrorCredential");
291
+ });
292
+
293
+ test("keeps a persisted auth marker while the refused credential is current", async () => {
294
+ const refused = claudeTokenDigest("sk-ant-oat-refused");
295
+ insertHistoryRow({
296
+ id: "auth-failed-2",
297
+ status: "failed",
298
+ cwd: "/tmp/project",
299
+ authErrorCode: "acp_claude_auth_required",
300
+ authErrorCredential: refused,
301
+ });
302
+ fakeStoredCredential = refused;
303
+
304
+ const result = await executeAcpStatus(
305
+ { acp_session_id: "auth-failed-2" },
306
+ context,
307
+ );
308
+
309
+ expect(JSON.parse(result.content)).toMatchObject({
310
+ id: "auth-failed-2",
311
+ status: "failed",
312
+ authErrorCode: "acp_claude_auth_required",
313
+ });
314
+ expect(result.content).not.toContain("authErrorCredential");
315
+ });
316
+
317
+ test("keeps an older live session when 50 newer history rows exist", async () => {
318
+ liveStates = [
319
+ {
320
+ ...RUNNING_STATE,
321
+ id: "old-live",
322
+ startedAt: 1,
323
+ },
324
+ ];
325
+ for (let i = 0; i < 50; i++) {
326
+ insertHistoryRow({
327
+ id: `hist-${i}`,
328
+ acpSessionId: `proto-hist-${i}`,
329
+ startedAt: 1000 + i,
330
+ status: "completed",
331
+ cwd: "/tmp/project",
332
+ });
333
+ }
334
+
335
+ const result = await executeAcpStatus({}, context);
336
+ const payload = JSON.parse(result.content) as Array<{
337
+ id: string;
338
+ status: string;
339
+ }>;
340
+
341
+ expect(payload).toHaveLength(50);
342
+ expect(payload.some((entry) => entry.id === "old-live")).toBe(true);
343
+ expect(payload.find((entry) => entry.id === "old-live")?.status).toBe(
344
+ "running",
345
+ );
346
+ expect(payload[0]?.id).toBe("hist-49");
347
+ expect(payload.some((entry) => entry.id === "hist-0")).toBe(false);
348
+ });
94
349
  });