@vellumai/assistant 0.4.16 → 0.4.17

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 (58) hide show
  1. package/Dockerfile +6 -6
  2. package/README.md +1 -2
  3. package/package.json +1 -1
  4. package/src/__tests__/call-controller.test.ts +1074 -751
  5. package/src/__tests__/call-routes-http.test.ts +329 -279
  6. package/src/__tests__/channel-approval-routes.test.ts +0 -11
  7. package/src/__tests__/channel-approvals.test.ts +227 -182
  8. package/src/__tests__/channel-guardian.test.ts +1 -0
  9. package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
  10. package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
  11. package/src/__tests__/conversation-routes.test.ts +71 -41
  12. package/src/__tests__/daemon-server-session-init.test.ts +258 -191
  13. package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
  14. package/src/__tests__/extract-email.test.ts +42 -0
  15. package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
  16. package/src/__tests__/gateway-only-guard.test.ts +54 -55
  17. package/src/__tests__/gmail-integration.test.ts +48 -46
  18. package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
  19. package/src/__tests__/guardian-outbound-http.test.ts +334 -208
  20. package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
  21. package/src/__tests__/guardian-routing-state.test.ts +257 -209
  22. package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
  23. package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
  24. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
  25. package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
  26. package/src/__tests__/ingress-reconcile.test.ts +184 -142
  27. package/src/__tests__/non-member-access-request.test.ts +291 -247
  28. package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
  29. package/src/__tests__/recording-intent-handler.test.ts +422 -291
  30. package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
  31. package/src/__tests__/runtime-events-sse.test.ts +67 -50
  32. package/src/__tests__/send-endpoint-busy.test.ts +314 -232
  33. package/src/__tests__/session-approval-overrides.test.ts +93 -91
  34. package/src/__tests__/sms-messaging-provider.test.ts +74 -47
  35. package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
  39. package/src/__tests__/twilio-config.test.ts +49 -41
  40. package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
  41. package/src/__tests__/twilio-routes.test.ts +389 -280
  42. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
  43. package/src/config/bundled-skills/messaging/SKILL.md +5 -4
  44. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
  45. package/src/config/env.ts +39 -29
  46. package/src/daemon/handlers/skills.ts +18 -10
  47. package/src/daemon/ipc-contract/messages.ts +1 -0
  48. package/src/daemon/ipc-contract/surfaces.ts +7 -1
  49. package/src/daemon/session-agent-loop-handlers.ts +5 -0
  50. package/src/daemon/session-agent-loop.ts +1 -1
  51. package/src/daemon/session-process.ts +1 -1
  52. package/src/daemon/session-surfaces.ts +42 -2
  53. package/src/runtime/auth/token-service.ts +74 -47
  54. package/src/sequence/reply-matcher.ts +10 -6
  55. package/src/skills/frontmatter.ts +9 -6
  56. package/src/tools/ui-surface/definitions.ts +2 -1
  57. package/src/util/platform.ts +0 -12
  58. package/docs/architecture/http-token-refresh.md +0 -274
@@ -4,11 +4,13 @@ import { inflateRawSync } from "node:zlib";
4
4
  import { eq } from "drizzle-orm";
5
5
  import { v4 as uuid } from "uuid";
6
6
 
7
+ import { getConfig } from "../../../../config/loader.js";
7
8
  import {
8
9
  addMessage,
9
10
  createConversation,
10
11
  } from "../../../../memory/conversation-store.js";
11
12
  import { getDb } from "../../../../memory/db.js";
13
+ import { indexMessageNow } from "../../../../memory/indexer.js";
12
14
  import {
13
15
  conversationKeys,
14
16
  conversations,
@@ -88,7 +90,9 @@ export async function run(
88
90
  imported = parseChatGPTExport(filePath);
89
91
  } catch (err) {
90
92
  return {
91
- content: `Error parsing export file: ${err instanceof Error ? err.message : String(err)}`,
93
+ content: `Error parsing export file: ${
94
+ err instanceof Error ? err.message : String(err)
95
+ }`,
92
96
  isError: true,
93
97
  };
94
98
  }
@@ -121,9 +125,15 @@ export async function run(
121
125
 
122
126
  const conversation = createConversation(conv.title);
123
127
 
128
+ // Skip indexing during insert so we can backfill original timestamps first
124
129
  for (const msg of conv.messages) {
125
- // Uses the daemon's addMessage which triggers memory indexing
126
- await addMessage(conversation.id, msg.role, JSON.stringify(msg.content));
130
+ await addMessage(
131
+ conversation.id,
132
+ msg.role,
133
+ JSON.stringify(msg.content),
134
+ undefined,
135
+ { skipIndexing: true },
136
+ );
127
137
  }
128
138
 
129
139
  // Override timestamps to match ChatGPT originals
@@ -140,11 +150,26 @@ export async function run(
140
150
  .orderBy(messagesTable.createdAt)
141
151
  .all();
142
152
 
153
+ const memoryConfig = getConfig().memory;
143
154
  for (let i = 0; i < dbMessages.length && i < conv.messages.length; i++) {
155
+ const originalTimestamp = conv.messages[i].createdAt;
144
156
  db.update(messagesTable)
145
- .set({ createdAt: conv.messages[i].createdAt })
157
+ .set({ createdAt: originalTimestamp })
146
158
  .where(eq(messagesTable.id, dbMessages[i].id))
147
159
  .run();
160
+
161
+ // Index with the original ChatGPT timestamp so memory segments
162
+ // reflect actual message age, not import time
163
+ indexMessageNow(
164
+ {
165
+ messageId: dbMessages[i].id,
166
+ conversationId: conversation.id,
167
+ role: conv.messages[i].role,
168
+ content: JSON.stringify(conv.messages[i].content),
169
+ createdAt: originalTimestamp,
170
+ },
171
+ memoryConfig,
172
+ );
148
173
  }
149
174
 
150
175
  db.insert(conversationKeys)
@@ -306,6 +306,7 @@ When a user asks to declutter, clean up, or organize their email — start scann
306
306
  1. **Scan**: Call `gmail_sender_digest` (or `messaging_sender_digest` for non-Gmail). Default query targets promotions from the last 90 days.
307
307
  2. **Present**: Show results as a `ui_show` table with `selectionMode: "multiple"`:
308
308
  - **Gmail columns (exactly 3)**: Sender, Emails Found, Unsub?
309
+ - **Unsub? cell values**: Use rich cell format: `{ "text": "Yes", "icon": "checkmark.circle.fill", "iconColor": "success" }` when `has_unsubscribe` is true, `{ "text": "No", "icon": "minus.circle", "iconColor": "muted" }` when false.
309
310
  - **Non-Gmail columns (exactly 2)**: Sender, Emails Found (omit the Unsub? column — unsubscribe is not available)
310
311
  - **Pre-select all rows** (`selected: true`) — users deselect what they want to keep
311
312
  - **Caption**: "Showing emails from last 90 days in Promotions" (or adjusted to match the query used)
@@ -313,11 +314,11 @@ When a user asks to declutter, clean up, or organize their email — start scann
313
314
  - **Non-Gmail action button (exactly 1)**: "Archive Selected" (primary). Do not offer an unsubscribe button — it is Gmail-specific. **NEVER offer Delete, Trash, or any destructive action.**
314
315
  3. **Wait for user action**: Stop and wait. Do NOT proceed to archiving or unsubscribing until the user clicks one of the action buttons on the table. When the user clicks an action button:
315
316
  - **Dismiss the table immediately** with `ui_dismiss` — it collapses to a completion chip
316
- - **Show a `task_progress` card** with one step per selected sender (e.g., "Archiving TechCrunch (247 emails)"). Update each step from `in_progress` → `completed` as each sender finishes.
317
+ - **Show a `task_progress` card** with steps for each phase (e.g., "Archiving 89 senders (2,400 emails)", "Unsubscribing from 72 senders"). Update each step from `in_progress` → `completed` as each phase finishes.
317
318
  - When all senders are processed, set the progress card's `status: "completed"`.
318
- 4. **Act on selection**: For each selected sender:
319
- - Use `gmail_batch_archive` (or `messaging_archive_by_sender` for non-Gmail) with `scan_id` + the selected senders' `id` values as `sender_ids` this resolves message IDs server-side without putting them in context
320
- - If Gmail and the action is "Archive & Unsubscribe" and `has_unsubscribe` is true, call `gmail_unsubscribe` with the sender's `newest_message_id`
319
+ 4. **Act on selection** batch, don't loop:
320
+ - **Archive all at once**: Call `gmail_batch_archive` (or `messaging_archive_by_sender` for non-Gmail) **once** with `scan_id` + **all** selected senders' `id` values in the `sender_ids` array. The tool resolves message IDs server-side and batches the Gmail API calls internally — never loop sender-by-sender.
321
+ - **Unsubscribe in bulk**: If Gmail and the action is "Archive & Unsubscribe", call `gmail_unsubscribe` for each sender that has `has_unsubscribe: true` but emit **all** unsubscribe tool calls in a **single assistant response** (parallel tool use) rather than one-at-a-time across separate turns.
321
322
  5. **Accurate summary**: The scan counts are exact — the `message_count` shown in the table matches the number of messages archived. Format: "Cleaned up [total_archived] emails from [sender_count] senders." For Gmail, append: "Unsubscribed from [unsub_count]."
322
323
  6. **Ongoing protection offer (Gmail only)**: After reporting results, offer auto-archive filters:
323
324
  - "Want me to set up auto-archive filters so future emails from these senders skip your inbox?"
@@ -54,17 +54,21 @@ function parseAddressList(header: string): string[] {
54
54
  * - `user@example.com (team <ops>)`
55
55
  *
56
56
  * Extracts all angle-bracketed segments and picks the last one containing `@`,
57
- * falling back to the last segment, then to the raw string. This handles both
58
- * display names with angle brackets and trailing RFC 5322 comments that may
59
- * contain angle brackets.
57
+ * preferring the actual mailbox over display-name fragments like
58
+ * `"Acme <support@acme.com>" <owner@example.com>`. If no segment contains `@`,
59
+ * strips angle-bracketed portions and parenthetical comments, returning the
60
+ * remaining text. This handles display names with angle brackets and trailing
61
+ * RFC 5322 comments.
60
62
  */
61
63
  function extractEmail(address: string): string {
62
- const segments = [...address.matchAll(/<([^>]+)>/g)].map((m) => m[1]);
64
+ // Strip parenthetical comments first to avoid matching addresses inside them
65
+ const cleaned = address.replace(/\(.*?\)/g, '');
66
+ const segments = [...cleaned.matchAll(/<([^>]+)>/g)].map((m) => m[1]);
63
67
  if (segments.length > 0) {
64
- const emailSegment = segments.findLast((s) => s.includes('@'));
65
- return (emailSegment ?? segments[segments.length - 1]).trim().toLowerCase();
68
+ const emailSegment = [...segments].reverse().find((s) => s.includes('@'));
69
+ if (emailSegment) return emailSegment.trim().toLowerCase();
66
70
  }
67
- return address.trim().toLowerCase();
71
+ return address.replace(/<[^>]+>/g, '').replace(/\(.*?\)/g, '').trim().toLowerCase();
68
72
  }
69
73
 
70
74
  export async function run(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
package/src/config/env.ts CHANGED
@@ -14,10 +14,13 @@
14
14
  * without circular imports.
15
15
  */
16
16
 
17
- import { getLogger } from '../util/logger.js';
18
- import { checkUnrecognizedEnvVars,getEnableMonitoring } from './env-registry.js';
17
+ import { getLogger } from "../util/logger.js";
18
+ import {
19
+ checkUnrecognizedEnvVars,
20
+ getEnableMonitoring,
21
+ } from "./env-registry.js";
19
22
 
20
- const log = getLogger('env');
23
+ const log = getLogger("env");
21
24
 
22
25
  // ── Helpers ──────────────────────────────────────────────────────────────────
23
26
 
@@ -35,7 +38,9 @@ function int(name: string, fallback?: number): number | undefined {
35
38
  if (!raw) return fallback;
36
39
  const n = parseInt(raw, 10);
37
40
  if (isNaN(n)) {
38
- throw new Error(`Invalid integer for ${name}: "${raw}"${fallback !== undefined ? ` (fallback: ${fallback})` : ''}`);
41
+ throw new Error(
42
+ `Invalid integer for ${name}: "${raw}"${fallback !== undefined ? ` (fallback: ${fallback})` : ""}`,
43
+ );
39
44
  }
40
45
  return n;
41
46
  }
@@ -45,7 +50,7 @@ function int(name: string, fallback?: number): number | undefined {
45
50
  const DEFAULT_GATEWAY_PORT = 7830;
46
51
 
47
52
  export function getGatewayPort(): number {
48
- return int('GATEWAY_PORT', DEFAULT_GATEWAY_PORT);
53
+ return int("GATEWAY_PORT", DEFAULT_GATEWAY_PORT);
49
54
  }
50
55
 
51
56
  /**
@@ -53,8 +58,8 @@ export function getGatewayPort(): number {
53
58
  * Prefers GATEWAY_INTERNAL_BASE_URL if set, otherwise derives from port.
54
59
  */
55
60
  export function getGatewayInternalBaseUrl(): string {
56
- const explicit = str('GATEWAY_INTERNAL_BASE_URL');
57
- if (explicit) return explicit.replace(/\/+$/, '');
61
+ const explicit = str("GATEWAY_INTERNAL_BASE_URL");
62
+ if (explicit) return explicit.replace(/\/+$/, "");
58
63
  return `http://127.0.0.1:${getGatewayPort()}`;
59
64
  }
60
65
 
@@ -62,7 +67,7 @@ export function getGatewayInternalBaseUrl(): string {
62
67
 
63
68
  /** Read the INGRESS_PUBLIC_BASE_URL env var (may be mutated at runtime by config handlers). */
64
69
  export function getIngressPublicBaseUrl(): string | undefined {
65
- return str('INGRESS_PUBLIC_BASE_URL');
70
+ return str("INGRESS_PUBLIC_BASE_URL");
66
71
  }
67
72
 
68
73
  /** Set or clear the INGRESS_PUBLIC_BASE_URL env var (used by config handlers). */
@@ -77,29 +82,32 @@ export function setIngressPublicBaseUrl(value: string | undefined): void {
77
82
  // ── Runtime HTTP ─────────────────────────────────────────────────────────────
78
83
 
79
84
  export function getRuntimeHttpPort(): number {
80
- return int('RUNTIME_HTTP_PORT') ?? 7821;
85
+ return int("RUNTIME_HTTP_PORT") ?? 7821;
81
86
  }
82
87
 
83
88
  export function getRuntimeHttpHost(): string {
84
- return str('RUNTIME_HTTP_HOST') || '127.0.0.1';
89
+ return str("RUNTIME_HTTP_HOST") || "127.0.0.1";
85
90
  }
86
91
 
87
92
  export function getRuntimeProxyBearerToken(): string | undefined {
88
- return str('RUNTIME_PROXY_BEARER_TOKEN');
93
+ return str("RUNTIME_PROXY_BEARER_TOKEN");
89
94
  }
90
95
 
91
96
  export function getRuntimeGatewayOriginSecret(): string | undefined {
92
- return str('RUNTIME_GATEWAY_ORIGIN_SECRET');
97
+ return str("RUNTIME_GATEWAY_ORIGIN_SECRET");
93
98
  }
94
99
 
95
100
  /**
96
101
  * True when HTTP API auth is disabled via DISABLE_HTTP_AUTH=true AND the
97
102
  * safety gate VELLUM_UNSAFE_AUTH_BYPASS=1 is also set. Without the safety
98
103
  * gate, the bypass is ignored.
104
+ *
105
+ * Also returns true in test environments (bun test sets NODE_ENV=test)
106
+ * so that tests don't need to initialize the JWT signing key.
99
107
  */
100
108
  export function isHttpAuthDisabled(): boolean {
101
- if (str('DISABLE_HTTP_AUTH')?.toLowerCase() !== 'true') return false;
102
- return str('VELLUM_UNSAFE_AUTH_BYPASS')?.trim() === '1';
109
+ if (str("DISABLE_HTTP_AUTH")?.toLowerCase() !== "true") return false;
110
+ return str("VELLUM_UNSAFE_AUTH_BYPASS")?.trim() === "1";
103
111
  }
104
112
 
105
113
  /**
@@ -107,39 +115,39 @@ export function isHttpAuthDisabled(): boolean {
107
115
  * VELLUM_UNSAFE_AUTH_BYPASS=1 is missing — used for warning messages.
108
116
  */
109
117
  export function hasUngatedHttpAuthDisabled(): boolean {
110
- if (str('DISABLE_HTTP_AUTH')?.toLowerCase() !== 'true') return false;
111
- return str('VELLUM_UNSAFE_AUTH_BYPASS')?.trim() !== '1';
118
+ if (str("DISABLE_HTTP_AUTH")?.toLowerCase() !== "true") return false;
119
+ return str("VELLUM_UNSAFE_AUTH_BYPASS")?.trim() !== "1";
112
120
  }
113
121
 
114
122
  // ── Twilio ───────────────────────────────────────────────────────────────────
115
123
 
116
124
  export function getTwilioPhoneNumberEnv(): string | undefined {
117
- return str('TWILIO_PHONE_NUMBER');
125
+ return str("TWILIO_PHONE_NUMBER");
118
126
  }
119
127
 
120
128
  export function getTwilioUserPhoneNumber(): string | undefined {
121
- return str('TWILIO_USER_PHONE_NUMBER');
129
+ return str("TWILIO_USER_PHONE_NUMBER");
122
130
  }
123
131
 
124
132
  export function getTwilioWssBaseUrl(): string | undefined {
125
- return str('TWILIO_WSS_BASE_URL');
133
+ return str("TWILIO_WSS_BASE_URL");
126
134
  }
127
135
 
128
136
  export function isTwilioWebhookValidationDisabled(): boolean {
129
137
  // Intentionally strict: only exact "true" disables validation (not "1").
130
138
  // This is a security-sensitive bypass — we don't want environments that
131
139
  // template booleans as "1" to silently skip webhook signature checks.
132
- return process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED === 'true';
140
+ return process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED === "true";
133
141
  }
134
142
 
135
143
  export function getCallWelcomeGreeting(): string | undefined {
136
- return str('CALL_WELCOME_GREETING');
144
+ return str("CALL_WELCOME_GREETING");
137
145
  }
138
146
 
139
147
  // ── Monitoring ───────────────────────────────────────────────────────────────
140
148
 
141
149
  export function getLogfireToken(): string | undefined {
142
- return str('LOGFIRE_TOKEN');
150
+ return str("LOGFIRE_TOKEN");
143
151
  }
144
152
 
145
153
  export function isMonitoringEnabled(): boolean {
@@ -147,25 +155,25 @@ export function isMonitoringEnabled(): boolean {
147
155
  }
148
156
 
149
157
  export function getSentryDsn(): string | undefined {
150
- return str('SENTRY_DSN');
158
+ return str("SENTRY_DSN");
151
159
  }
152
160
 
153
161
  // ── Qdrant ───────────────────────────────────────────────────────────────────
154
162
 
155
163
  export function getQdrantUrlEnv(): string | undefined {
156
- return str('QDRANT_URL');
164
+ return str("QDRANT_URL");
157
165
  }
158
166
 
159
167
  // ── Ollama ───────────────────────────────────────────────────────────────────
160
168
 
161
169
  export function getOllamaBaseUrlEnv(): string | undefined {
162
- return str('OLLAMA_BASE_URL');
170
+ return str("OLLAMA_BASE_URL");
163
171
  }
164
172
 
165
173
  // ── Platform ─────────────────────────────────────────────────────────────────
166
174
 
167
175
  export function getPlatformBaseUrl(): string {
168
- return str('PLATFORM_BASE_URL') ?? '';
176
+ return str("PLATFORM_BASE_URL") ?? "";
169
177
  }
170
178
 
171
179
  /**
@@ -173,7 +181,7 @@ export function getPlatformBaseUrl(): string {
173
181
  * Required for registering callback routes when containerized.
174
182
  */
175
183
  export function getPlatformAssistantId(): string {
176
- return str('PLATFORM_ASSISTANT_ID') ?? '';
184
+ return str("PLATFORM_ASSISTANT_ID") ?? "";
177
185
  }
178
186
 
179
187
  /**
@@ -181,7 +189,7 @@ export function getPlatformAssistantId(): string {
181
189
  * with the platform's internal gateway callback route registration endpoint.
182
190
  */
183
191
  export function getPlatformInternalApiKey(): string {
184
- return str('PLATFORM_INTERNAL_API_KEY') ?? '';
192
+ return str("PLATFORM_INTERNAL_API_KEY") ?? "";
185
193
  }
186
194
 
187
195
  // ── Startup validation ──────────────────────────────────────────────────────
@@ -203,7 +211,9 @@ export function validateEnv(): void {
203
211
  }
204
212
 
205
213
  if (getTwilioWssBaseUrl()) {
206
- log.warn('TWILIO_WSS_BASE_URL env var is deprecated. Relay URL is now derived from ingress.publicBaseUrl.');
214
+ log.warn(
215
+ "TWILIO_WSS_BASE_URL env var is deprecated. Relay URL is now derived from ingress.publicBaseUrl.",
216
+ );
207
217
  }
208
218
 
209
219
  for (const warning of checkUnrecognizedEnvVars()) {
@@ -300,6 +300,7 @@ export async function handleSkillsInstall(
300
300
  );
301
301
  if (bundled) {
302
302
  // Auto-enable the bundled skill so it's immediately usable
303
+ let autoEnabled = false;
303
304
  try {
304
305
  const raw = loadRawConfig();
305
306
  ensureSkillEntry(raw, msg.slug).enabled = true;
@@ -319,6 +320,7 @@ export async function handleSkillsInstall(
319
320
  CONFIG_RELOAD_DEBOUNCE_MS,
320
321
  );
321
322
  ctx.updateConfigFingerprint();
323
+ autoEnabled = true;
322
324
  } catch (err) {
323
325
  log.warn(
324
326
  { err, skillId: msg.slug },
@@ -331,11 +333,13 @@ export async function handleSkillsInstall(
331
333
  operation: "install",
332
334
  success: true,
333
335
  });
334
- ctx.broadcast({
335
- type: "skills_state_changed",
336
- name: msg.slug,
337
- state: "enabled",
338
- });
336
+ if (autoEnabled) {
337
+ ctx.broadcast({
338
+ type: "skills_state_changed",
339
+ name: msg.slug,
340
+ state: "enabled",
341
+ });
342
+ }
339
343
  return;
340
344
  }
341
345
 
@@ -357,6 +361,7 @@ export async function handleSkillsInstall(
357
361
  loadSkillCatalog();
358
362
 
359
363
  // Auto-enable the newly installed skill so it's immediately usable.
364
+ let autoEnabled = false;
360
365
  try {
361
366
  const raw = loadRawConfig();
362
367
  ensureSkillEntry(raw, skillId).enabled = true;
@@ -376,6 +381,7 @@ export async function handleSkillsInstall(
376
381
  CONFIG_RELOAD_DEBOUNCE_MS,
377
382
  );
378
383
  ctx.updateConfigFingerprint();
384
+ autoEnabled = true;
379
385
  } catch (err) {
380
386
  log.warn({ err, skillId }, "Failed to auto-enable installed skill");
381
387
  }
@@ -385,11 +391,13 @@ export async function handleSkillsInstall(
385
391
  operation: "install",
386
392
  success: true,
387
393
  });
388
- ctx.broadcast({
389
- type: "skills_state_changed",
390
- name: skillId,
391
- state: "enabled",
392
- });
394
+ if (autoEnabled) {
395
+ ctx.broadcast({
396
+ type: "skills_state_changed",
397
+ name: skillId,
398
+ state: "enabled",
399
+ });
400
+ }
393
401
  } catch (err) {
394
402
  const message = err instanceof Error ? err.message : String(err);
395
403
  log.error({ err }, "Failed to install skill");
@@ -240,6 +240,7 @@ export interface AssistantActivityState {
240
240
  | 'thinking_delta'
241
241
  | 'first_text_delta'
242
242
  | 'tool_use_start'
243
+ | 'tool_result_received'
243
244
  | 'confirmation_requested'
244
245
  | 'confirmation_resolved'
245
246
  | 'message_complete'
@@ -104,9 +104,15 @@ export interface TableColumn {
104
104
  width?: number;
105
105
  }
106
106
 
107
+ export interface TableCellValue {
108
+ text: string;
109
+ icon?: string; // SF Symbol name
110
+ iconColor?: string; // semantic token: "success" | "warning" | "error" | "muted"
111
+ }
112
+
107
113
  export interface TableRow {
108
114
  id: string;
109
- cells: Record<string, string>;
115
+ cells: Record<string, string | TableCellValue>;
110
116
  selectable?: boolean;
111
117
  selected?: boolean;
112
118
  }
@@ -319,6 +319,11 @@ export function handleToolResult(
319
319
  // call re-emits the activity state transitions.
320
320
  state.firstTextDeltaEmitted = false;
321
321
  state.firstThinkingDeltaEmitted = false;
322
+
323
+ // Emit activity state immediately so clients show a thinking indicator
324
+ // during the gap between tool_result and the next thinking_delta/text_delta.
325
+ const statusText = `Processing ${friendlyToolName(state.lastCompletedToolName ?? '')} results`;
326
+ deps.ctx.emitActivityState('thinking', 'tool_result_received', 'assistant_turn', deps.reqId, statusText);
322
327
  }
323
328
 
324
329
  export function handleError(
@@ -133,7 +133,7 @@ export interface AgentLoopSessionContext {
133
133
 
134
134
  emitActivityState(
135
135
  phase: 'idle' | 'thinking' | 'streaming' | 'tool_running' | 'awaiting_confirmation',
136
- reason: 'message_dequeued' | 'thinking_delta' | 'first_text_delta' | 'tool_use_start' | 'confirmation_requested' | 'confirmation_resolved' | 'message_complete' | 'generation_cancelled' | 'error_terminal',
136
+ reason: 'message_dequeued' | 'thinking_delta' | 'first_text_delta' | 'tool_use_start' | 'tool_result_received' | 'confirmation_requested' | 'confirmation_resolved' | 'message_complete' | 'generation_cancelled' | 'error_terminal',
137
137
  anchor?: 'assistant_turn' | 'user_turn' | 'global',
138
138
  requestId?: string,
139
139
  statusText?: string,
@@ -89,7 +89,7 @@ export interface ProcessSessionContext {
89
89
  setTurnInterfaceContext(ctx: TurnInterfaceContext): void;
90
90
  emitActivityState(
91
91
  phase: 'idle' | 'thinking' | 'streaming' | 'tool_running' | 'awaiting_confirmation',
92
- reason: 'message_dequeued' | 'thinking_delta' | 'first_text_delta' | 'tool_use_start' | 'confirmation_requested' | 'confirmation_resolved' | 'message_complete' | 'generation_cancelled' | 'error_terminal',
92
+ reason: 'message_dequeued' | 'thinking_delta' | 'first_text_delta' | 'tool_use_start' | 'tool_result_received' | 'confirmation_requested' | 'confirmation_resolved' | 'message_complete' | 'generation_cancelled' | 'error_terminal',
93
93
  anchor?: 'assistant_turn' | 'user_turn' | 'global',
94
94
  requestId?: string,
95
95
  statusText?: string,
@@ -151,6 +151,10 @@ export interface SurfaceSessionContext {
151
151
  onEvent: (msg: ServerMessage) => void,
152
152
  requestId: string,
153
153
  activeSurfaceId?: string,
154
+ currentPage?: string,
155
+ metadata?: Record<string, unknown>,
156
+ options?: { isInteractive?: boolean },
157
+ displayContent?: string,
154
158
  ): { queued: boolean; rejected?: boolean; requestId: string };
155
159
  getQueueDepth(): number;
156
160
  processMessage(
@@ -158,6 +162,10 @@ export interface SurfaceSessionContext {
158
162
  attachments: never[],
159
163
  onEvent: (msg: ServerMessage) => void,
160
164
  requestId?: string,
165
+ activeSurfaceId?: string,
166
+ currentPage?: string,
167
+ options?: { isInteractive?: boolean },
168
+ displayContent?: string,
161
169
  ): Promise<string>;
162
170
  /** Serialize operations on a given surface to prevent read-modify-write races. */
163
171
  withSurface<T>(surfaceId: string, fn: () => T | Promise<T>): Promise<T>;
@@ -406,6 +414,8 @@ export function handleSurfaceAction(ctx: SurfaceSessionContext, surfaceId: strin
406
414
  fallbackContent += `\n\nAction data: ${JSON.stringify(data)}`;
407
415
  }
408
416
  const content = prompt || fallbackContent;
417
+ // Show the user plain-text instead of raw JSON action data.
418
+ const displayContent = prompt ? undefined : buildUserFacingLabel(pending.surfaceType, actionId, data);
409
419
 
410
420
  const requestId = uuid();
411
421
  ctx.surfaceActionRequestIds.add(requestId);
@@ -426,7 +436,7 @@ export function handleSurfaceAction(ctx: SurfaceSessionContext, surfaceId: strin
426
436
  attributes: { source: 'surface_action', surfaceId, actionId },
427
437
  });
428
438
 
429
- const result = ctx.enqueueMessage(content, [], onEvent, requestId, surfaceId);
439
+ const result = ctx.enqueueMessage(content, [], onEvent, requestId, surfaceId, undefined, undefined, undefined, displayContent);
430
440
  if (result.queued) {
431
441
  const position = ctx.getQueueDepth();
432
442
  if (!retainPending) {
@@ -467,7 +477,7 @@ export function handleSurfaceAction(ctx: SurfaceSessionContext, surfaceId: strin
467
477
  ctx.pendingSurfaceActions.delete(surfaceId);
468
478
  }
469
479
  log.info({ surfaceId, actionId, requestId }, 'Processing surface action as follow-up');
470
- ctx.processMessage(content, [], onEvent, requestId).catch((err) => {
480
+ ctx.processMessage(content, [], onEvent, requestId, surfaceId, undefined, undefined, displayContent).catch((err) => {
471
481
  const message = err instanceof Error ? err.message : String(err);
472
482
  log.error({ err, surfaceId, actionId }, 'Error processing surface action');
473
483
  onEvent({ type: 'error', message: `Failed to process surface action: ${message}` });
@@ -545,6 +555,36 @@ export function buildCompletionSummary(surfaceType: string | undefined, actionId
545
555
  return actionId.charAt(0).toUpperCase() + actionId.slice(1);
546
556
  }
547
557
 
558
+ /**
559
+ * Build a plain-text label shown to the user in the chat bubble for a
560
+ * surface action. Unlike `buildCompletionSummary` (which is for the LLM),
561
+ * this produces natural language the user can glance at.
562
+ */
563
+ export function buildUserFacingLabel(surfaceType: string | undefined, actionId: string, data?: Record<string, unknown>): string {
564
+ const count = (data?.selectedIds as string[] | undefined)?.length;
565
+
566
+ if (surfaceType === 'confirmation') {
567
+ if (actionId === 'cancel') return 'Cancelled';
568
+ if (actionId === 'confirm') return 'Confirmed';
569
+ return `Selected: ${actionId}`;
570
+ }
571
+ if (surfaceType === 'form') return 'Submitted';
572
+
573
+ // Table / list selection actions
574
+ if (count) {
575
+ const noun = count === 1 ? 'item' : 'items';
576
+ const action = actionId
577
+ .replace(/_/g, ' ')
578
+ .replace(/\b\w/g, (c) => c.toUpperCase());
579
+ return `${action} ${count} ${noun}`;
580
+ }
581
+
582
+ // Generic fallback — humanize the action ID
583
+ return actionId
584
+ .replace(/_/g, ' ')
585
+ .replace(/\b\w/g, (c) => c.toUpperCase());
586
+ }
587
+
548
588
  /**
549
589
  * Resolve a proxy tool call that targets a UI surface.
550
590
  * Handles ui_show, ui_update, ui_dismiss, request_file, computer_use_request_control, and app_open.