@puredesktop/puredesktop-ui-bridge 2.1.7 → 2.1.9

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 (119) hide show
  1. package/package.json +89 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +57 -3
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/collectionImagePaste.ts +19 -1
  14. package/src/bridge/context.mjs +32 -0
  15. package/src/bridge/credentials.d.mts +62 -0
  16. package/src/bridge/credentials.mjs +53 -0
  17. package/src/bridge/documents.d.mts +76 -0
  18. package/src/bridge/documents.mjs +40 -0
  19. package/src/bridge/events.d.mts +1 -0
  20. package/src/bridge/events.mjs +1 -0
  21. package/src/bridge/googleAuth.ts +7 -152
  22. package/src/bridge/mcpClient.d.mts +74 -0
  23. package/src/bridge/mcpClient.mjs +79 -0
  24. package/src/bridge/methods.d.mts +45 -0
  25. package/src/bridge/methods.mjs +46 -0
  26. package/src/bridge/notifications.d.mts +12 -0
  27. package/src/bridge/notifications.mjs +36 -0
  28. package/src/bridge/operations.d.mts +44 -0
  29. package/src/bridge/operations.mjs +40 -0
  30. package/src/bridge/pureRender/base.css +44 -0
  31. package/src/bridge/pureRender/document.ts +29 -2
  32. package/src/bridge/pureRender/index.ts +3 -0
  33. package/src/bridge/pureRender/normalize.ts +167 -0
  34. package/src/bridge/pureRender/sanitizeExportBody.test.ts +51 -0
  35. package/src/bridge/pureRender/sanitizeExportBody.ts +61 -0
  36. package/src/bridge/pureRender/styles.ts +125 -5
  37. package/src/bridge/pureRender/theme.test.ts +259 -0
  38. package/src/bridge/pureRender/theme.ts +19 -1
  39. package/src/bridge/pureRender/types.ts +33 -0
  40. package/src/bridge/pureRender/visualLayoutInspection.test.ts +26 -0
  41. package/src/bridge/pureRender/visualLayoutInspection.ts +75 -0
  42. package/src/bridge/react/useDocumentHotkeys.ts +41 -0
  43. package/src/bridge/react/useDocumentLifecycle.tsx +359 -0
  44. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  45. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  46. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -0
  47. package/src/bridge/react/usePlatformAppSettings.tsx +104 -0
  48. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -0
  49. package/src/bridge/react/usePlatformJsonStore.tsx +149 -0
  50. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  51. package/src/bridge/storage.d.mts +23 -10
  52. package/src/bridge/storage.mjs +6 -3
  53. package/src/bridge/storage.test.ts +6 -6
  54. package/src/bridge/types.ts +7 -0
  55. package/src/components/agents/AgentComposer.tsx +10 -6
  56. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  57. package/src/components/agents/AgentDrawerPanel.tsx +23 -62
  58. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  59. package/src/components/agents/AgentMessageList.tsx +4 -4
  60. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  61. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  62. package/src/components/agents/AgentToolCallCard.tsx +20 -20
  63. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  64. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  65. package/src/components/agents/ContextPicker.tsx +239 -0
  66. package/src/components/agents/QuestionRequestList.tsx +278 -0
  67. package/src/components/agents/agentPanelStyles.ts +8 -31
  68. package/src/components/agents/agentTypes.ts +25 -41
  69. package/src/components/agents/index.ts +4 -0
  70. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  71. package/src/components/chrome/WorkspaceTabStrip.tsx +22 -4
  72. package/src/components/chrome/workspaceTabTypes.ts +2 -0
  73. package/src/components/common/chat/ChatBox.tsx +9 -9
  74. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  75. package/src/components/common/chat/ChatThread.tsx +23 -7
  76. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  77. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  78. package/src/components/common/connections/index.ts +5 -0
  79. package/src/components/common/containers/AppFrame.test.tsx +4 -4
  80. package/src/components/common/containers/AppFrame.tsx +26 -10
  81. package/src/components/common/containers/AppHeader.tsx +37 -15
  82. package/src/components/common/documents/DocumentHeaderActions.tsx +206 -0
  83. package/src/components/common/documents/DocumentSwitcher.tsx +1181 -0
  84. package/src/components/common/documents/index.ts +8 -0
  85. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  86. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  87. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  88. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  89. package/src/components/common/overlays/Modal.tsx +13 -2
  90. package/src/components/common/research/EvidenceDossier.tsx +1232 -150
  91. package/src/components/common/research/index.ts +2 -0
  92. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  93. package/src/components/print-design/PrintDesignPanel.test.tsx +430 -40
  94. package/src/components/print-design/PrintDesignPanel.tsx +1770 -207
  95. package/src/components/print-design/index.ts +2 -0
  96. package/src/components/print-design/previewContent.test.ts +32 -0
  97. package/src/components/print-design/previewContent.ts +31 -0
  98. package/src/context/buildContextDocument.test.ts +240 -0
  99. package/src/context/buildContextDocument.ts +309 -0
  100. package/src/context/contextAccess.ts +66 -0
  101. package/src/context/contextConfig.ts +72 -0
  102. package/src/context/contextDocument.test.ts +155 -0
  103. package/src/context/contextDocument.ts +300 -0
  104. package/src/context/contextSections.test.ts +88 -0
  105. package/src/context/contextSections.ts +84 -0
  106. package/src/context/htmlToContextMarkdown.test.ts +134 -0
  107. package/src/context/htmlToContextMarkdown.ts +369 -0
  108. package/src/ics/generateIcs.test.ts +153 -0
  109. package/src/ics/generateIcs.ts +197 -0
  110. package/src/index.ts +3 -0
  111. package/src/net/httpRetry.test.ts +117 -0
  112. package/src/net/httpRetry.ts +111 -0
  113. package/src/theme/appAccents.test.ts +36 -35
  114. package/src/theme/appAccents.ts +93 -104
  115. package/src/theme/appIdentityCss.mjs +178 -229
  116. package/src/theme/appIdentityCss.ts +222 -231
  117. package/src/theme/themes/dark.ts +3 -1
  118. package/src/theme/themes/light.ts +3 -1
  119. package/src/bridge/googleProviderConfig.ts +0 -73
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Curated MCP servers preloaded with PureDesktop — one click to add from the
3
+ * assistant Tools library (nothing is enabled until the user confirms).
4
+ * Selection favors keyless servers; entries that need a key say so and the
5
+ * UI asks for the bearer token after the first failing test.
6
+ *
7
+ * `stdio` presets run locally and need their runner on PATH (`npx` → Node,
8
+ * `uvx` → uv); remote presets need nothing. The Test button verifies either
9
+ * way, so a missing runner or a renamed package fails loudly, not silently.
10
+ */
11
+
12
+ export type McpServerPresetTransport = 'http' | 'sse' | 'stdio'
13
+
14
+ export type McpServerPresetCategory =
15
+ | 'academic'
16
+ | 'knowledge'
17
+ | 'documentation'
18
+ | 'developer'
19
+
20
+ export type McpServerPresetAuth = 'none' | 'bearer' | 'apiKey'
21
+
22
+ export interface McpServerPreset {
23
+ /** Registry id — `[a-z0-9-]+`, stable across releases. */
24
+ id: string
25
+ label: string
26
+ description: string
27
+ category: McpServerPresetCategory
28
+ transport: McpServerPresetTransport
29
+ url?: string
30
+ command?: string
31
+ args?: string[]
32
+ /**
33
+ * How the server authenticates: `bearer` (Authorization header on a remote
34
+ * server) or `apiKey` (an env var, named by `apiKeyEnvVar`, on a local stdio
35
+ * server). Omitted → no auth.
36
+ */
37
+ auth?: McpServerPresetAuth
38
+ /** Env var the key is passed as, for `auth: 'apiKey'` servers. */
39
+ apiKeyEnvVar?: string
40
+ /** Preset needs a secret (bearer token or API key) before it will connect. */
41
+ requiresToken?: boolean
42
+ /** Short caveat shown with the entry (runner needed, rate limits, …). */
43
+ note?: string
44
+ /**
45
+ * Site to pull a favicon from for the row logo. Auto-derived from `url` for
46
+ * remote presets; set explicitly for local (stdio) presets that still have a
47
+ * recognizable home page (arXiv, PubMed, …) so they don't fall back to plain
48
+ * initials.
49
+ */
50
+ iconDomain?: string
51
+ }
52
+
53
+ export const MCP_SERVER_PRESETS: readonly McpServerPreset[] = [
54
+ // ---- Academic research ----------------------------------------------------
55
+ {
56
+ id: 'arxiv',
57
+ label: 'arXiv',
58
+ description:
59
+ 'Search arXiv preprints, fetch abstracts, and download papers for analysis.',
60
+ category: 'academic',
61
+ transport: 'stdio',
62
+ command: 'uvx',
63
+ args: ['arxiv-mcp-server'],
64
+ note: 'Python server — needs uv (one-time install).',
65
+ iconDomain: 'arxiv.org',
66
+ },
67
+ {
68
+ id: 'pubmed',
69
+ label: 'PubMed',
70
+ description:
71
+ 'Search PubMed biomedical literature and fetch article metadata and abstracts.',
72
+ category: 'academic',
73
+ transport: 'stdio',
74
+ command: 'uvx',
75
+ args: ['pubmedmcp'],
76
+ note: 'Python server — needs uv (one-time install).',
77
+ iconDomain: 'pubmed.ncbi.nlm.nih.gov',
78
+ },
79
+ {
80
+ id: 'wikipedia',
81
+ label: 'Wikipedia',
82
+ description:
83
+ 'Look up and summarize Wikipedia articles with full-text search.',
84
+ category: 'academic',
85
+ transport: 'stdio',
86
+ command: 'uvx',
87
+ args: ['wikipedia-mcp'],
88
+ note: 'Python server — needs uv (one-time install).',
89
+ iconDomain: 'wikipedia.org',
90
+ },
91
+ {
92
+ id: 'huggingface',
93
+ label: 'Hugging Face',
94
+ description:
95
+ 'Search models, datasets, Spaces, and daily papers on the Hugging Face Hub.',
96
+ category: 'academic',
97
+ transport: 'http',
98
+ url: 'https://huggingface.co/mcp',
99
+ note: 'Remote server; anonymous access is rate-limited.',
100
+ },
101
+ {
102
+ id: 'exa',
103
+ label: 'Exa research',
104
+ description:
105
+ 'Neural web + research-paper search with full-text retrieval — strong for academic literature.',
106
+ category: 'academic',
107
+ transport: 'stdio',
108
+ command: 'npx',
109
+ args: ['-y', 'exa-mcp-server'],
110
+ auth: 'apiKey',
111
+ apiKeyEnvVar: 'EXA_API_KEY',
112
+ requiresToken: true,
113
+ note: 'Runs via npx (Node). Needs a free Exa API key (asked for after adding).',
114
+ iconDomain: 'exa.ai',
115
+ },
116
+
117
+ // ---- General knowledge work ----------------------------------------------
118
+ {
119
+ id: 'fetch',
120
+ label: 'Web fetch',
121
+ description:
122
+ 'Fetch a web page and convert it to markdown for reading and citation.',
123
+ category: 'knowledge',
124
+ transport: 'stdio',
125
+ command: 'uvx',
126
+ args: ['mcp-server-fetch'],
127
+ note: 'Python reference server — needs uv (one-time install).',
128
+ iconDomain: 'developer.mozilla.org',
129
+ },
130
+ {
131
+ id: 'duckduckgo',
132
+ label: 'DuckDuckGo search',
133
+ description: 'Key-free web search with content extraction.',
134
+ category: 'knowledge',
135
+ transport: 'stdio',
136
+ command: 'uvx',
137
+ args: ['duckduckgo-mcp-server'],
138
+ note: 'Python server — needs uv (one-time install).',
139
+ iconDomain: 'duckduckgo.com',
140
+ },
141
+ {
142
+ id: 'memory',
143
+ label: 'Memory (knowledge graph)',
144
+ description:
145
+ 'Persistent knowledge-graph memory agents can read and extend across sessions.',
146
+ category: 'knowledge',
147
+ transport: 'stdio',
148
+ command: 'npx',
149
+ args: ['-y', '@modelcontextprotocol/server-memory'],
150
+ note: 'Reference server; runs locally via npx.',
151
+ iconDomain: 'modelcontextprotocol.io',
152
+ },
153
+ {
154
+ id: 'sequential-thinking',
155
+ label: 'Sequential thinking',
156
+ description:
157
+ 'Structured step-by-step reasoning scratchpad for complex problems.',
158
+ category: 'knowledge',
159
+ transport: 'stdio',
160
+ command: 'npx',
161
+ args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
162
+ note: 'Reference server; runs locally via npx.',
163
+ iconDomain: 'modelcontextprotocol.io',
164
+ },
165
+ {
166
+ id: 'time',
167
+ label: 'Time & timezones',
168
+ description: 'Current time and timezone conversion.',
169
+ category: 'knowledge',
170
+ transport: 'stdio',
171
+ command: 'uvx',
172
+ args: ['mcp-server-time'],
173
+ note: 'Python reference server — needs uv (one-time install).',
174
+ iconDomain: 'modelcontextprotocol.io',
175
+ },
176
+ {
177
+ id: 'tavily',
178
+ label: 'Tavily search',
179
+ description:
180
+ 'Web search, extract, and crawl tuned for LLM answers with citations.',
181
+ category: 'knowledge',
182
+ transport: 'stdio',
183
+ command: 'npx',
184
+ args: ['-y', 'tavily-mcp'],
185
+ auth: 'apiKey',
186
+ apiKeyEnvVar: 'TAVILY_API_KEY',
187
+ requiresToken: true,
188
+ note: 'Runs via npx (Node). Needs a Tavily API key (asked for after adding).',
189
+ iconDomain: 'tavily.com',
190
+ },
191
+ {
192
+ id: 'brave-search',
193
+ label: 'Brave Search',
194
+ description: 'Independent web and local search index.',
195
+ category: 'knowledge',
196
+ transport: 'stdio',
197
+ command: 'npx',
198
+ args: ['-y', '@modelcontextprotocol/server-brave-search'],
199
+ auth: 'apiKey',
200
+ apiKeyEnvVar: 'BRAVE_API_KEY',
201
+ requiresToken: true,
202
+ note: 'Runs via npx (Node). Needs a Brave Search API key (asked for after adding).',
203
+ iconDomain: 'search.brave.com',
204
+ },
205
+ {
206
+ id: 'firecrawl',
207
+ label: 'Firecrawl',
208
+ description:
209
+ 'Scrape, crawl, and structure whole websites into clean markdown or JSON.',
210
+ category: 'knowledge',
211
+ transport: 'stdio',
212
+ command: 'npx',
213
+ args: ['-y', 'firecrawl-mcp'],
214
+ auth: 'apiKey',
215
+ apiKeyEnvVar: 'FIRECRAWL_API_KEY',
216
+ requiresToken: true,
217
+ note: 'Runs via npx (Node). Needs a Firecrawl API key (asked for after adding).',
218
+ iconDomain: 'firecrawl.dev',
219
+ },
220
+
221
+ // ---- Documentation --------------------------------------------------------
222
+ {
223
+ id: 'context7',
224
+ label: 'Context7 docs',
225
+ description:
226
+ 'Version-accurate library and framework documentation for grounded answers.',
227
+ category: 'documentation',
228
+ transport: 'http',
229
+ url: 'https://mcp.context7.com/mcp',
230
+ note: 'Remote server; anonymous tier is rate-limited.',
231
+ },
232
+ {
233
+ id: 'deepwiki',
234
+ label: 'DeepWiki',
235
+ description:
236
+ 'Ask questions about public GitHub repositories via generated wikis.',
237
+ category: 'documentation',
238
+ transport: 'http',
239
+ url: 'https://mcp.deepwiki.com/mcp',
240
+ note: 'Remote server, key-free.',
241
+ },
242
+ {
243
+ id: 'microsoft-learn',
244
+ label: 'Microsoft Learn',
245
+ description:
246
+ 'Search and read Microsoft/Azure documentation grounded to current pages.',
247
+ category: 'documentation',
248
+ transport: 'http',
249
+ url: 'https://learn.microsoft.com/api/mcp',
250
+ note: 'Remote server, key-free.',
251
+ },
252
+ {
253
+ id: 'cloudflare-docs',
254
+ label: 'Cloudflare Docs',
255
+ description:
256
+ 'Search and read Cloudflare developer documentation, grounded to current pages.',
257
+ category: 'documentation',
258
+ transport: 'sse',
259
+ url: 'https://docs.mcp.cloudflare.com/sse',
260
+ note: 'Remote server, key-free.',
261
+ iconDomain: 'cloudflare.com',
262
+ },
263
+
264
+ // ---- Developer ------------------------------------------------------------
265
+ {
266
+ id: 'git',
267
+ label: 'Git (local repos)',
268
+ description:
269
+ 'Inspect and search local git repositories: log, diff, blame, status.',
270
+ category: 'developer',
271
+ transport: 'stdio',
272
+ command: 'uvx',
273
+ args: ['mcp-server-git'],
274
+ note: 'Python reference server — needs uv (one-time install).',
275
+ iconDomain: 'git-scm.com',
276
+ },
277
+ {
278
+ id: 'github',
279
+ label: 'GitHub',
280
+ description:
281
+ 'Repositories, issues, and pull requests through the official GitHub MCP.',
282
+ category: 'developer',
283
+ transport: 'http',
284
+ url: 'https://api.githubcopilot.com/mcp/',
285
+ auth: 'bearer',
286
+ requiresToken: true,
287
+ note: 'Needs a GitHub personal access token (asked for after adding).',
288
+ iconDomain: 'github.com',
289
+ },
290
+ {
291
+ id: 'playwright',
292
+ label: 'Playwright browser',
293
+ description:
294
+ 'Drive a real browser: navigate, click, fill, and read pages for the agent.',
295
+ category: 'developer',
296
+ transport: 'stdio',
297
+ command: 'npx',
298
+ args: ['-y', '@playwright/mcp@latest'],
299
+ note: 'Runs via npx (Node); downloads a browser on first use.',
300
+ iconDomain: 'playwright.dev',
301
+ },
302
+ {
303
+ id: 'everything',
304
+ label: 'Everything (demo)',
305
+ description:
306
+ 'Reference server exercising every MCP feature — handy for trying tools out.',
307
+ category: 'developer',
308
+ transport: 'stdio',
309
+ command: 'npx',
310
+ args: ['-y', '@modelcontextprotocol/server-everything'],
311
+ note: 'Reference server; runs locally via npx.',
312
+ iconDomain: 'modelcontextprotocol.io',
313
+ },
314
+ {
315
+ id: 'chart',
316
+ label: 'Charts (AntV)',
317
+ description:
318
+ 'Generate 25+ chart types (line, bar, pie, maps, …) as images from data.',
319
+ category: 'developer',
320
+ transport: 'stdio',
321
+ command: 'npx',
322
+ args: ['-y', '@antv/mcp-server-chart'],
323
+ note: 'Runs via npx (Node).',
324
+ iconDomain: 'antv.antgroup.com',
325
+ },
326
+ {
327
+ id: 'stripe',
328
+ label: 'Stripe',
329
+ description:
330
+ 'Query and manage Stripe: customers, payments, invoices, and products.',
331
+ category: 'developer',
332
+ transport: 'http',
333
+ url: 'https://mcp.stripe.com',
334
+ auth: 'bearer',
335
+ requiresToken: true,
336
+ note: 'Needs a Stripe restricted API key as the bearer token (asked for after adding).',
337
+ iconDomain: 'stripe.com',
338
+ },
339
+ ]
340
+
341
+ export const MCP_SERVER_PRESET_CATEGORY_LABELS: Record<
342
+ McpServerPresetCategory,
343
+ string
344
+ > = {
345
+ academic: 'Academic research',
346
+ knowledge: 'Knowledge work',
347
+ documentation: 'Documentation',
348
+ developer: 'Developer',
349
+ }
350
+
351
+ export function findMcpServerPreset(id: string): McpServerPreset | undefined {
352
+ return MCP_SERVER_PRESETS.find(preset => preset.id === id)
353
+ }
354
+
355
+ /**
356
+ * What a preset needs on the user's machine to run:
357
+ * - `remote` — nothing (an HTTP/SSE endpoint the shell just calls);
358
+ * - `node` — a system `npx` (Node.js), usually already present;
359
+ * - `python` — `uvx` (uv), the least commonly installed.
360
+ *
361
+ * The Tools UI privileges lower-friction runtimes and clearly flags the ones
362
+ * that need an extra install, rather than requiring uv across the board.
363
+ */
364
+ export type McpPresetRuntime = 'remote' | 'node' | 'python'
365
+
366
+ const NODE_RUNNERS = new Set(['npx', 'node', 'bunx'])
367
+
368
+ export function mcpPresetRuntime(
369
+ preset: Pick<McpServerPreset, 'transport' | 'command'>,
370
+ ): McpPresetRuntime {
371
+ if (preset.transport !== 'stdio') return 'remote'
372
+ return preset.command && NODE_RUNNERS.has(preset.command) ? 'node' : 'python'
373
+ }
374
+
375
+ /** Sort rank so no-install servers come first, then Node, then Python. */
376
+ export const MCP_PRESET_RUNTIME_RANK: Record<McpPresetRuntime, number> = {
377
+ remote: 0,
378
+ node: 1,
379
+ python: 2,
380
+ }
@@ -8,6 +8,13 @@ export {
8
8
  export { createIdleLiveTurn, reduceAgentLiveTurn } from './agentLiveReducer.js'
9
9
  export { unresolvedToolCalls } from './unresolvedToolCalls.js'
10
10
  export { derivePendingToolCalls } from './pendingToolCalls.js'
11
+ export {
12
+ derivePendingAsks,
13
+ sessionPendingAskCalls,
14
+ type PendingAsk,
15
+ type PendingAskDelegation,
16
+ type PendingAskSession,
17
+ } from './pendingAsks.js'
11
18
  export { toolRequiresApproval } from './toolRequiresApproval.js'
12
19
  export { mergeUsageTurn } from './mergeUsageTurn.js'
13
20
  export {
@@ -8,7 +8,7 @@ import {
8
8
  type AgentUiToolCall,
9
9
  } from '../../components/agents/agentTypes.js'
10
10
  import { derivePendingToolCalls } from './pendingToolCalls.js'
11
- import { toolRequiresApproval } from './toolRequiresApproval.js'
11
+ import { sessionPendingAskCalls } from './pendingAsks.js'
12
12
 
13
13
  const VISIBLE_ROLES = new Set(['user', 'assistant', 'tool'])
14
14
 
@@ -76,19 +76,22 @@ export function mapPendingToolCalls(
76
76
  )
77
77
  }
78
78
 
79
- /** Pending tools that still need explicit user approval. */
79
+ /**
80
+ * Pending tools that still need explicit user approval — the shared
81
+ * pending-asks selector's per-session slice (docs/09 §Card states).
82
+ */
80
83
  export function mapApprovalPendingToolCalls(
81
84
  snapshot: PlatformAgentRunSnapshot | null,
82
85
  policy: PlatformAgentSessionToolPolicy | undefined,
83
86
  acceptedPendingToolCallIds: readonly string[] = [],
84
87
  ): AgentUiToolCall[] {
85
88
  if (!snapshot || !policy) return []
86
- const accepted = new Set(acceptedPendingToolCallIds)
87
- return derivePendingToolCalls(snapshot.messages, snapshot.state)
88
- .filter(
89
- call => !accepted.has(call.id) && toolRequiresApproval(call.name, policy),
90
- )
91
- .map(mapToolCall)
89
+ return sessionPendingAskCalls({
90
+ state: snapshot.state,
91
+ messages: snapshot.messages,
92
+ acceptedPendingToolCallIds,
93
+ toolPolicy: policy,
94
+ })
92
95
  }
93
96
 
94
97
  function mapToolCall(call: AgentUiToolCall): AgentUiToolCall {
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { PlatformAgentMessage } from '../../bridge/agentTypes.js'
3
+ import {
4
+ derivePendingAsks,
5
+ sessionPendingAskCalls,
6
+ type PendingAskSession,
7
+ } from './pendingAsks.js'
8
+
9
+ function pendingMessages(
10
+ calls: Array<{ id: string; name: string }>,
11
+ ): PlatformAgentMessage[] {
12
+ return [
13
+ { role: 'user', content: 'do the thing' },
14
+ {
15
+ role: 'assistant',
16
+ content: '',
17
+ toolCalls: calls.map(call => ({ ...call, arguments: {} })),
18
+ },
19
+ ]
20
+ }
21
+
22
+ function session(
23
+ overrides: Partial<PendingAskSession> = {},
24
+ ): PendingAskSession {
25
+ return {
26
+ scope: 'global',
27
+ sessionId: 'parent-1',
28
+ state: 'pending_tools',
29
+ messages: pendingMessages([{ id: 'call-1', name: 'harness.invoke_agent' }]),
30
+ ...overrides,
31
+ }
32
+ }
33
+
34
+ describe('sessionPendingAskCalls', () => {
35
+ it('lists unresolved pending calls', () => {
36
+ expect(sessionPendingAskCalls(session())).toEqual([
37
+ { id: 'call-1', name: 'harness.invoke_agent', arguments: {} },
38
+ ])
39
+ })
40
+
41
+ it('an accepted call is in flight, not an ask', () => {
42
+ expect(
43
+ sessionPendingAskCalls(
44
+ session({ acceptedPendingToolCallIds: ['call-1'] }),
45
+ ),
46
+ ).toEqual([])
47
+ })
48
+
49
+ it('a settled session has no asks', () => {
50
+ expect(
51
+ sessionPendingAskCalls(session({ state: 'awaiting_user' })),
52
+ ).toEqual([])
53
+ })
54
+
55
+ it('policy-approved calls are not asks when a policy is supplied', () => {
56
+ expect(
57
+ sessionPendingAskCalls(
58
+ session({
59
+ toolPolicy: {
60
+ allowedTargetApps: [],
61
+ routeReadinessMs: 1_000,
62
+ handlerResponseMs: 1_000,
63
+ approvalOverrides: { 'harness.invoke_agent': false },
64
+ },
65
+ }),
66
+ ),
67
+ ).toEqual([])
68
+ })
69
+ })
70
+
71
+ describe('derivePendingAsks', () => {
72
+ it('an active child delegation ask outranks the parent ask', () => {
73
+ const asks = derivePendingAsks(session(), [
74
+ {
75
+ status: 'running',
76
+ childAppSlug: 'writer',
77
+ child: session({
78
+ scope: 'app',
79
+ sessionId: 'child-1',
80
+ appSlug: 'writer',
81
+ messages: pendingMessages([
82
+ { id: 'call-2', name: 'writer.createDocument' },
83
+ ]),
84
+ }),
85
+ },
86
+ ])
87
+
88
+ expect(asks).toHaveLength(2)
89
+ expect(asks[0]).toEqual({
90
+ session: { scope: 'app', sessionId: 'child-1', appSlug: 'writer' },
91
+ origin: 'child',
92
+ childAppSlug: 'writer',
93
+ calls: [{ id: 'call-2', name: 'writer.createDocument', arguments: {} }],
94
+ })
95
+ expect(asks[1]).toEqual(
96
+ expect.objectContaining({ origin: 'parent' }),
97
+ )
98
+ })
99
+
100
+ it('settled delegations and unreadable children are ignored', () => {
101
+ const asks = derivePendingAsks(session(), [
102
+ {
103
+ status: 'completed',
104
+ childAppSlug: 'writer',
105
+ child: session({ sessionId: 'child-done' }),
106
+ },
107
+ { status: 'running', childAppSlug: 'book', child: null },
108
+ ])
109
+ expect(asks).toEqual([expect.objectContaining({ origin: 'parent' })])
110
+ })
111
+
112
+ it('no asks anywhere yields an empty list', () => {
113
+ expect(
114
+ derivePendingAsks(session({ state: 'awaiting_user' })),
115
+ ).toEqual([])
116
+ })
117
+ })
@@ -0,0 +1,110 @@
1
+ import type {
2
+ PlatformAgentMessage,
3
+ PlatformAgentSessionScope,
4
+ PlatformAgentSessionToolPolicy,
5
+ } from '../../bridge/agentTypes.js'
6
+ import type { AgentUiToolCall } from '../../components/agents/agentTypes.js'
7
+ import { derivePendingToolCalls } from './pendingToolCalls.js'
8
+ import { toolRequiresApproval } from './toolRequiresApproval.js'
9
+
10
+ /**
11
+ * The one pending-asks selector (docs/09-missions.md §Card states): both the
12
+ * agent drawer and the mission card derive "what is this run waiting on the
13
+ * human for?" from here — manual pending calls minus accepted, with active
14
+ * child-delegation asks taking precedence over the parent's. No surface
15
+ * keeps its own derivation that can disagree. Approving from either surface
16
+ * is the same operation against the same session.
17
+ */
18
+
19
+ export interface PendingAskSession {
20
+ scope: PlatformAgentSessionScope
21
+ sessionId: string
22
+ appSlug?: string
23
+ state: string
24
+ messages: readonly PlatformAgentMessage[]
25
+ acceptedPendingToolCallIds?: readonly string[]
26
+ /** When supplied, calls the policy auto-approves are not asks. */
27
+ toolPolicy?: PlatformAgentSessionToolPolicy
28
+ }
29
+
30
+ export interface PendingAskDelegation {
31
+ status: string
32
+ childAppSlug: string
33
+ /** The delegated child's session, when the caller could read it. */
34
+ child?: PendingAskSession | null
35
+ }
36
+
37
+ export interface PendingAsk {
38
+ session: {
39
+ scope: PlatformAgentSessionScope
40
+ sessionId: string
41
+ appSlug?: string
42
+ }
43
+ /** Child asks come from an active delegated app agent. */
44
+ origin: 'child' | 'parent'
45
+ childAppSlug?: string
46
+ calls: AgentUiToolCall[]
47
+ }
48
+
49
+ /**
50
+ * One session's slice of the contract: unresolved manual calls, minus the
51
+ * accepted-but-still-settling ones (an accepted call is IN FLIGHT — a
52
+ * delegation or app tool holds it open — not waiting on the human).
53
+ */
54
+ export function sessionPendingAskCalls(
55
+ session: Pick<
56
+ PendingAskSession,
57
+ 'state' | 'messages' | 'acceptedPendingToolCallIds' | 'toolPolicy'
58
+ >,
59
+ ): AgentUiToolCall[] {
60
+ const accepted = new Set(session.acceptedPendingToolCallIds ?? [])
61
+ return derivePendingToolCalls(session.messages, session.state)
62
+ .filter(call => !accepted.has(call.id))
63
+ .filter(call =>
64
+ session.toolPolicy
65
+ ? toolRequiresApproval(call.name, session.toolPolicy)
66
+ : true,
67
+ )
68
+ .map(call => ({ id: call.id, name: call.name, arguments: call.arguments }))
69
+ }
70
+
71
+ function sessionRef(session: PendingAskSession): PendingAsk['session'] {
72
+ return {
73
+ scope: session.scope,
74
+ sessionId: session.sessionId,
75
+ ...(session.appSlug ? { appSlug: session.appSlug } : {}),
76
+ }
77
+ }
78
+
79
+ function delegationIsActive(status: string): boolean {
80
+ return status === 'pending' || status === 'running'
81
+ }
82
+
83
+ /**
84
+ * All open asks for one run, child-first: while a delegated app agent is
85
+ * asking, that ask IS the run's current question — a parent-level
86
+ * `harness.invoke_agent` ask shown instead would be stale.
87
+ */
88
+ export function derivePendingAsks(
89
+ parent: PendingAskSession,
90
+ delegations: readonly PendingAskDelegation[] = [],
91
+ ): PendingAsk[] {
92
+ const asks: PendingAsk[] = []
93
+ for (const delegation of delegations) {
94
+ if (!delegationIsActive(delegation.status)) continue
95
+ if (!delegation.child) continue
96
+ const calls = sessionPendingAskCalls(delegation.child)
97
+ if (calls.length === 0) continue
98
+ asks.push({
99
+ session: sessionRef(delegation.child),
100
+ origin: 'child',
101
+ childAppSlug: delegation.childAppSlug,
102
+ calls,
103
+ })
104
+ }
105
+ const parentCalls = sessionPendingAskCalls(parent)
106
+ if (parentCalls.length > 0) {
107
+ asks.push({ session: sessionRef(parent), origin: 'parent', calls: parentCalls })
108
+ }
109
+ return asks
110
+ }