@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.
- package/Dockerfile +6 -6
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +0 -11
- package/src/__tests__/channel-approvals.test.ts +227 -182
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/daemon-server-session-init.test.ts +258 -191
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
- package/src/__tests__/extract-email.test.ts +42 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
- package/src/__tests__/gateway-only-guard.test.ts +54 -55
- package/src/__tests__/gmail-integration.test.ts +48 -46
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
- package/src/__tests__/guardian-outbound-http.test.ts +334 -208
- package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
- package/src/__tests__/guardian-routing-state.test.ts +257 -209
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
- package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
- package/src/__tests__/ingress-reconcile.test.ts +184 -142
- package/src/__tests__/non-member-access-request.test.ts +291 -247
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/recording-intent-handler.test.ts +422 -291
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
- package/src/__tests__/runtime-events-sse.test.ts +67 -50
- package/src/__tests__/send-endpoint-busy.test.ts +314 -232
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
- package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
- package/src/__tests__/twilio-routes.test.ts +389 -280
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
- package/src/config/bundled-skills/messaging/SKILL.md +5 -4
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +74 -47
- package/src/sequence/reply-matcher.ts +10 -6
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +2 -1
- package/src/util/platform.ts +0 -12
- 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: ${
|
|
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
|
-
|
|
126
|
-
|
|
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:
|
|
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
|
|
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
|
|
319
|
-
-
|
|
320
|
-
- If Gmail and the action is "Archive & Unsubscribe"
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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.
|
|
65
|
-
|
|
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
|
|
18
|
-
import {
|
|
17
|
+
import { getLogger } from "../util/logger.js";
|
|
18
|
+
import {
|
|
19
|
+
checkUnrecognizedEnvVars,
|
|
20
|
+
getEnableMonitoring,
|
|
21
|
+
} from "./env-registry.js";
|
|
19
22
|
|
|
20
|
-
const log = getLogger(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
85
|
+
return int("RUNTIME_HTTP_PORT") ?? 7821;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
export function getRuntimeHttpHost(): string {
|
|
84
|
-
return str(
|
|
89
|
+
return str("RUNTIME_HTTP_HOST") || "127.0.0.1";
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
export function getRuntimeProxyBearerToken(): string | undefined {
|
|
88
|
-
return str(
|
|
93
|
+
return str("RUNTIME_PROXY_BEARER_TOKEN");
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
export function getRuntimeGatewayOriginSecret(): string | undefined {
|
|
92
|
-
return str(
|
|
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(
|
|
102
|
-
return str(
|
|
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(
|
|
111
|
-
return str(
|
|
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(
|
|
125
|
+
return str("TWILIO_PHONE_NUMBER");
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
export function getTwilioUserPhoneNumber(): string | undefined {
|
|
121
|
-
return str(
|
|
129
|
+
return str("TWILIO_USER_PHONE_NUMBER");
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
export function getTwilioWssBaseUrl(): string | undefined {
|
|
125
|
-
return str(
|
|
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 ===
|
|
140
|
+
return process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED === "true";
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
export function getCallWelcomeGreeting(): string | undefined {
|
|
136
|
-
return str(
|
|
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(
|
|
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(
|
|
158
|
+
return str("SENTRY_DSN");
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
// ── Qdrant ───────────────────────────────────────────────────────────────────
|
|
154
162
|
|
|
155
163
|
export function getQdrantUrlEnv(): string | undefined {
|
|
156
|
-
return str(
|
|
164
|
+
return str("QDRANT_URL");
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
// ── Ollama ───────────────────────────────────────────────────────────────────
|
|
160
168
|
|
|
161
169
|
export function getOllamaBaseUrlEnv(): string | undefined {
|
|
162
|
-
return str(
|
|
170
|
+
return str("OLLAMA_BASE_URL");
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
// ── Platform ─────────────────────────────────────────────────────────────────
|
|
166
174
|
|
|
167
175
|
export function getPlatformBaseUrl(): string {
|
|
168
|
-
return str(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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");
|
|
@@ -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.
|