@vellumai/assistant 0.9.0-staging.1 → 0.9.0

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 (36) hide show
  1. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
  2. package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
  3. package/openapi.yaml +99 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/app-compiler.test.ts +7 -1
  6. package/src/__tests__/app-executors.test.ts +43 -0
  7. package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
  8. package/src/__tests__/dynamic-page-surface.test.ts +94 -0
  9. package/src/__tests__/mock-gateway-ipc.ts +23 -0
  10. package/src/__tests__/resolve-app-id.test.ts +56 -0
  11. package/src/bundler/package-resolver.ts +0 -1
  12. package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
  13. package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
  14. package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
  15. package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
  16. package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
  17. package/src/daemon/conversation-surfaces.ts +92 -3
  18. package/src/notifications/home-feed-side-effect.ts +27 -10
  19. package/src/runtime/channel-invite-transports/telegram.ts +6 -5
  20. package/src/runtime/channel-invite-transports/voice.ts +2 -2
  21. package/src/runtime/channel-invite-types.ts +4 -2
  22. package/src/runtime/channel-retry-sweep.ts +19 -41
  23. package/src/runtime/finalize-event-delivery.ts +72 -0
  24. package/src/runtime/routes/channel-delivery-routes.ts +11 -7
  25. package/src/runtime/routes/channel-route-definitions.ts +3 -0
  26. package/src/runtime/routes/inbound-message-handler.ts +13 -12
  27. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +12 -36
  28. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
  29. package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
  30. package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
  31. package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
  32. package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
  33. package/src/schedule/schedule-store.ts +30 -0
  34. package/src/tools/apps/executors.ts +23 -0
  35. package/src/tools/apps/resolve-app-id.ts +42 -0
  36. package/src/tools/ui-surface/definitions.ts +43 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Gateway → daemon inbound payload contract.
3
+ *
4
+ * Zod schema defining the wire format for messages forwarded from the
5
+ * gateway to the daemon via `POST /v1/channels/inbound`. Both services
6
+ * import from here so the contract is enforced at compile time.
7
+ *
8
+ * The gateway constructs this payload in `forwardToRuntime()` from the
9
+ * normalized `GatewayInboundEvent`; the daemon validates and consumes
10
+ * it in `handleChannelInbound()`.
11
+ */
12
+
13
+ import { z } from "zod";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Command intent (channel-initiated commands, e.g. Telegram /start)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const CommandIntentSchema = z.object({
20
+ type: z.string(),
21
+ payload: z.string().optional(),
22
+ });
23
+
24
+ export type CommandIntent = z.infer<typeof CommandIntentSchema>;
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Source metadata — structured fields forwarded from the gateway's
28
+ // normalized inbound event. Replaces the untyped Record<string, unknown>.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export const SourceMetadataSchema = z
32
+ .object({
33
+ /** Provider-assigned update/event ID. */
34
+ updateId: z.string().optional(),
35
+ /** Provider message ID (e.g. Slack message `ts`). */
36
+ messageId: z.string().optional(),
37
+ /** Provider chat type (e.g. Telegram "private", "group"). */
38
+ chatType: z.string().optional(),
39
+ /** Thread/conversation-group ID (e.g. Slack `thread_ts`). */
40
+ threadId: z.string().optional(),
41
+ /** Channel name (e.g. Slack channel display name). */
42
+ channelName: z.string().optional(),
43
+ /** Actor's language code (e.g. "en", "es"). */
44
+ languageCode: z.string().optional(),
45
+ /** Whether the actor is a bot. */
46
+ isBot: z.boolean().optional(),
47
+ /** Actor's IANA timezone (e.g. "America/Los_Angeles"). */
48
+ timezone: z.string().optional(),
49
+ /** Human-readable timezone label (e.g. "Pacific Daylight Time"). */
50
+ timezoneLabel: z.string().optional(),
51
+ /** UTC offset in seconds. */
52
+ timezoneOffsetSeconds: z.number().optional(),
53
+ /** Slack-specific: actor is from an external workspace (Slack Connect). */
54
+ isStranger: z.boolean().optional(),
55
+ /** Slack-specific: actor is a guest / restricted account. */
56
+ isRestricted: z.boolean().optional(),
57
+ /** Transport-layer hints forwarded from the channel adapter. */
58
+ hints: z.array(z.string()).optional(),
59
+ /** Transport-layer UX brief. */
60
+ uxBrief: z.string().optional(),
61
+ /** Client-provided timezone for date formatting. */
62
+ clientTimezone: z.string().optional(),
63
+ /** Channel command intent (e.g. Telegram /start). */
64
+ commandIntent: CommandIntentSchema.optional(),
65
+ /** Slack-specific: whether the bot was @-mentioned. */
66
+ slackBotMentioned: z.boolean().optional(),
67
+ /** Slack workspace/team ID. */
68
+ account: z.string().optional(),
69
+
70
+ // Email-specific fields
71
+ /** Email subject line. */
72
+ emailSubject: z.string().optional(),
73
+ /** Email recipient address. */
74
+ emailRecipient: z.string().optional(),
75
+ /** Email In-Reply-To header. */
76
+ emailInReplyTo: z.string().optional(),
77
+ /** Email References header. */
78
+ emailReferences: z.string().optional(),
79
+ })
80
+ .passthrough();
81
+
82
+ export type SourceMetadata = z.infer<typeof SourceMetadataSchema>;
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Runtime inbound payload — the full wire format
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export const RuntimeInboundPayloadSchema = z.object({
89
+ sourceChannel: z.string(),
90
+ interface: z.string(),
91
+ conversationExternalId: z.string(),
92
+ externalMessageId: z.string(),
93
+ content: z.string(),
94
+ isEdit: z.boolean().optional(),
95
+ callbackQueryId: z.string().optional(),
96
+ callbackData: z.string().optional(),
97
+ actorDisplayName: z.string().optional(),
98
+ actorExternalId: z.string(),
99
+ actorUsername: z.string().optional(),
100
+ sourceMetadata: SourceMetadataSchema.optional(),
101
+ attachmentIds: z.array(z.string()).optional(),
102
+ replyCallbackUrl: z.string().optional(),
103
+ });
104
+
105
+ export type RuntimeInboundPayload = z.infer<typeof RuntimeInboundPayloadSchema>;
@@ -32,3 +32,15 @@ export type {
32
32
  } from "./types.js";
33
33
 
34
34
  export { noopLogger } from "./types.js";
35
+
36
+ export {
37
+ CommandIntentSchema,
38
+ RuntimeInboundPayloadSchema,
39
+ SourceMetadataSchema,
40
+ } from "./inbound-contract.js";
41
+
42
+ export type {
43
+ CommandIntent,
44
+ RuntimeInboundPayload,
45
+ SourceMetadata,
46
+ } from "./inbound-contract.js";
package/openapi.yaml CHANGED
@@ -3874,6 +3874,105 @@ paths:
3874
3874
  description: Receive an inbound message from a channel integration.
3875
3875
  tags:
3876
3876
  - channels
3877
+ requestBody:
3878
+ required: true
3879
+ content:
3880
+ application/json:
3881
+ schema:
3882
+ type: object
3883
+ properties:
3884
+ sourceChannel:
3885
+ type: string
3886
+ interface:
3887
+ type: string
3888
+ conversationExternalId:
3889
+ type: string
3890
+ externalMessageId:
3891
+ type: string
3892
+ content:
3893
+ type: string
3894
+ isEdit:
3895
+ type: boolean
3896
+ callbackQueryId:
3897
+ type: string
3898
+ callbackData:
3899
+ type: string
3900
+ actorDisplayName:
3901
+ type: string
3902
+ actorExternalId:
3903
+ type: string
3904
+ actorUsername:
3905
+ type: string
3906
+ sourceMetadata:
3907
+ type: object
3908
+ properties:
3909
+ updateId:
3910
+ type: string
3911
+ messageId:
3912
+ type: string
3913
+ chatType:
3914
+ type: string
3915
+ threadId:
3916
+ type: string
3917
+ channelName:
3918
+ type: string
3919
+ languageCode:
3920
+ type: string
3921
+ isBot:
3922
+ type: boolean
3923
+ timezone:
3924
+ type: string
3925
+ timezoneLabel:
3926
+ type: string
3927
+ timezoneOffsetSeconds:
3928
+ type: number
3929
+ isStranger:
3930
+ type: boolean
3931
+ isRestricted:
3932
+ type: boolean
3933
+ hints:
3934
+ type: array
3935
+ items:
3936
+ type: string
3937
+ uxBrief:
3938
+ type: string
3939
+ clientTimezone:
3940
+ type: string
3941
+ commandIntent:
3942
+ type: object
3943
+ properties:
3944
+ type:
3945
+ type: string
3946
+ payload:
3947
+ type: string
3948
+ required:
3949
+ - type
3950
+ slackBotMentioned:
3951
+ type: boolean
3952
+ account:
3953
+ type: string
3954
+ emailSubject:
3955
+ type: string
3956
+ emailRecipient:
3957
+ type: string
3958
+ emailInReplyTo:
3959
+ type: string
3960
+ emailReferences:
3961
+ type: string
3962
+ additionalProperties: {}
3963
+ attachmentIds:
3964
+ type: array
3965
+ items:
3966
+ type: string
3967
+ replyCallbackUrl:
3968
+ type: string
3969
+ required:
3970
+ - sourceChannel
3971
+ - interface
3972
+ - conversationExternalId
3973
+ - externalMessageId
3974
+ - content
3975
+ - actorExternalId
3877
3976
  responses:
3878
3977
  "200":
3879
3978
  description: Successful response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-staging.1",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -450,6 +450,12 @@ describe("package-resolver", () => {
450
450
  expect(ALLOWED_PACKAGES).toContain("lodash-es");
451
451
  expect(ALLOWED_PACKAGES).toContain("zod");
452
452
  expect(ALLOWED_PACKAGES).toContain("clsx");
453
- expect(ALLOWED_PACKAGES).toContain("lucide");
453
+ });
454
+
455
+ // `lucide` (the vanilla package) unpacks to ~29 MB, far above the resolver's
456
+ // size cap, so it is installed then deleted and never resolves. It also
457
+ // exports icon data arrays, not Preact components. Apps use inline SVG.
458
+ test("ALLOWED_PACKAGES excludes lucide", () => {
459
+ expect(ALLOWED_PACKAGES).not.toContain("lucide");
454
460
  });
455
461
  });
@@ -168,6 +168,49 @@ describe("executeAppCreate", () => {
168
168
  expect(parsed.next_steps).toContain("app_refresh");
169
169
  });
170
170
 
171
+ test("associates the new app with its conversation when a conversationId is given", async () => {
172
+ const app = makeMultifileApp({ id: "app-xyz", name: "Assoc App" });
173
+ const associated: Array<{ appId: string; conversationId: string }> = [];
174
+ const store: AppStore = {
175
+ ...mockStore(app, {}),
176
+ addAppConversationId: (appId, conversationId) => {
177
+ associated.push({ appId, conversationId });
178
+ return true;
179
+ },
180
+ };
181
+
182
+ const result = await executeAppCreate(
183
+ { name: "Assoc App" },
184
+ store,
185
+ undefined,
186
+ "conv-assoc-1",
187
+ );
188
+
189
+ expect(result.isError).toBe(false);
190
+ expect(associated).toEqual([
191
+ { appId: "app-xyz", conversationId: "conv-assoc-1" },
192
+ ]);
193
+ });
194
+
195
+ test("a failed conversation association does not fail the create", async () => {
196
+ const app = makeMultifileApp({ id: "app-throw", name: "Throw App" });
197
+ const store: AppStore = {
198
+ ...mockStore(app, {}),
199
+ addAppConversationId: () => {
200
+ throw new Error("disk gone");
201
+ },
202
+ };
203
+
204
+ const result = await executeAppCreate(
205
+ { name: "Throw App" },
206
+ store,
207
+ undefined,
208
+ "conv-assoc-2",
209
+ );
210
+
211
+ expect(result.isError).toBe(false);
212
+ });
213
+
171
214
  test("skips auto_open on scaffold even when proxy resolver is available", async () => {
172
215
  const files: Record<string, string> = {};
173
216
  const app = makeMultifileApp({ name: "New App" });
@@ -416,6 +416,103 @@ describe("ui_surface_update persistence", () => {
416
416
  });
417
417
  });
418
418
 
419
+ describe("ui_dismiss persisted-state convergence", () => {
420
+ let writes: Array<{ id: string; content: unknown }> = [];
421
+
422
+ beforeEach(() => {
423
+ writes = [];
424
+ updateMessageContentSpy = (id: string, content: string) => {
425
+ writes.push({ id, content: JSON.parse(content) });
426
+ };
427
+ getMessagesImpl = () => [];
428
+ cancelPendingSurfaceDataPersists();
429
+ });
430
+
431
+ afterEach(() => {
432
+ cancelPendingSurfaceDataPersists();
433
+ });
434
+
435
+ test("passive dismiss drops the surface from the turn snapshot and strips the persisted block", async () => {
436
+ const sent: ServerMessage[] = [];
437
+ const ctx = makeContext(sent);
438
+ const surfaceId = "surface-dismiss-1";
439
+ // A progress card the model marked `completed` while leaving step 4 spinning.
440
+ const data: CardSurfaceData = {
441
+ title: "Refreshing dashboard",
442
+ body: "",
443
+ template: "task_progress",
444
+ templateData: {
445
+ status: "completed",
446
+ steps: [{ label: "Surface today's numbers", status: "in_progress" }],
447
+ },
448
+ };
449
+ ctx.surfaceState.set(surfaceId, { surfaceType: "card", data });
450
+ ctx.currentTurnSurfaces.push({ surfaceId, surfaceType: "card", data });
451
+ seedRows([
452
+ {
453
+ id: "msg-dismiss",
454
+ content: [
455
+ { type: "text", text: "done" },
456
+ { type: "ui_surface", surfaceId, surfaceType: "card", data },
457
+ ],
458
+ },
459
+ ]);
460
+
461
+ const result = await surfaceProxyResolver(ctx, "ui_dismiss", {
462
+ surface_id: surfaceId,
463
+ });
464
+ expect(result.isError).toBe(false);
465
+ expect(result.content).toBe("Surface dismissed");
466
+
467
+ // Removed from the pending turn snapshot so turn completion never re-appends it.
468
+ expect(
469
+ ctx.currentTurnSurfaces.find((s) => s.surfaceId === surfaceId),
470
+ ).toBeUndefined();
471
+
472
+ // The already-persisted block is stripped; the sibling text block survives.
473
+ expect(writes).toHaveLength(1);
474
+ const blocks = writes[0].content as Array<Record<string, unknown>>;
475
+ expect(blocks.find((b) => b.type === "ui_surface")).toBeUndefined();
476
+ expect(blocks.find((b) => b.type === "text")).toBeDefined();
477
+
478
+ // A passive dismiss event (not a completion) was emitted to the client.
479
+ expect(sent.some((m) => m.type === "ui_surface_dismiss")).toBe(true);
480
+ expect(sent.some((m) => m.type === "ui_surface_complete")).toBe(false);
481
+ });
482
+
483
+ test("dismiss cancels a pending debounced persist so a stale update cannot re-add the block", async () => {
484
+ const sent: ServerMessage[] = [];
485
+ const ctx = makeContext(sent);
486
+ const surfaceId = "surface-dismiss-2";
487
+ const data: CardSurfaceData = {
488
+ title: "x",
489
+ body: "",
490
+ template: "task_progress",
491
+ templateData: { status: "in_progress" },
492
+ };
493
+ ctx.surfaceState.set(surfaceId, { surfaceType: "card", data });
494
+ seedRows([
495
+ {
496
+ id: "msg-dismiss-2",
497
+ content: [{ type: "ui_surface", surfaceId, surfaceType: "card", data }],
498
+ },
499
+ ]);
500
+
501
+ // A final ui_update is still inside the debounce window when dismiss fires.
502
+ scheduleSurfaceDataPersist("conv-persist-1", surfaceId, {
503
+ ...data,
504
+ templateData: { status: "completed" },
505
+ } as SurfaceData);
506
+
507
+ await surfaceProxyResolver(ctx, "ui_dismiss", { surface_id: surfaceId });
508
+ const writeCountAfterDismiss = writes.length;
509
+
510
+ // The cancelled debounce must not fire a write that resurrects the block.
511
+ await new Promise((r) => setTimeout(r, 600));
512
+ expect(writes).toHaveLength(writeCountAfterDismiss);
513
+ });
514
+ });
515
+
419
516
  describe("standalone surface DB persistence", () => {
420
517
  let writes: Array<{ id: string; content: unknown }> = [];
421
518
 
@@ -206,6 +206,100 @@ describe("ui_show dynamic_page app substitute guard", () => {
206
206
  });
207
207
  });
208
208
 
209
+ describe("ui_show empty card guard", () => {
210
+ function makeCtx(onProxy: () => void) {
211
+ return {
212
+ conversationId: "conversation-123",
213
+ workingDir: "/tmp",
214
+ trustClass: "guardian" as const,
215
+ proxyToolResolver: async () => {
216
+ onProxy();
217
+ return { content: "proxied", isError: false };
218
+ },
219
+ };
220
+ }
221
+
222
+ test("rejects a card carrying only a title and does not proxy", async () => {
223
+ let proxied = false;
224
+ const result = await uiShowTool.execute(
225
+ {
226
+ surface_type: "card",
227
+ title: "Vellum Internal Usage app",
228
+ activity: "Showing progress",
229
+ data: {},
230
+ },
231
+ makeCtx(() => {
232
+ proxied = true;
233
+ }),
234
+ );
235
+
236
+ expect(result.isError).toBe(true);
237
+ expect(result.content).toContain("card requires content");
238
+ expect(proxied).toBe(false);
239
+ });
240
+
241
+ test("rejects a card with no content at all", async () => {
242
+ let proxied = false;
243
+ const result = await uiShowTool.execute(
244
+ { surface_type: "card", data: {} },
245
+ makeCtx(() => {
246
+ proxied = true;
247
+ }),
248
+ );
249
+
250
+ expect(result.isError).toBe(true);
251
+ expect(proxied).toBe(false);
252
+ });
253
+
254
+ test("allows a card with a body", async () => {
255
+ let proxied = false;
256
+ const result = await uiShowTool.execute(
257
+ { surface_type: "card", data: { title: "Plain", body: "hi" } },
258
+ makeCtx(() => {
259
+ proxied = true;
260
+ }),
261
+ );
262
+
263
+ expect(result.isError).toBe(false);
264
+ expect(proxied).toBe(true);
265
+ });
266
+
267
+ test("allows a task_progress card with empty data (template renders a shell)", async () => {
268
+ let proxied = false;
269
+ const result = await uiShowTool.execute(
270
+ {
271
+ surface_type: "card",
272
+ template: "task_progress",
273
+ templateData: { status: "in_progress", steps: [] },
274
+ },
275
+ makeCtx(() => {
276
+ proxied = true;
277
+ }),
278
+ );
279
+
280
+ expect(result.isError).toBe(false);
281
+ expect(proxied).toBe(true);
282
+ });
283
+
284
+ test("allows an action-only card", async () => {
285
+ let proxied = false;
286
+ const result = await uiShowTool.execute(
287
+ {
288
+ surface_type: "card",
289
+ title: "Confirm",
290
+ actions: [{ id: "ok", label: "OK" }],
291
+ data: {},
292
+ },
293
+ makeCtx(() => {
294
+ proxied = true;
295
+ }),
296
+ );
297
+
298
+ expect(result.isError).toBe(false);
299
+ expect(proxied).toBe(true);
300
+ });
301
+ });
302
+
209
303
  // ---------------------------------------------------------------------------
210
304
  // task_progress ui_show appends the update hint to its return value
211
305
  // ---------------------------------------------------------------------------
@@ -82,6 +82,28 @@ const GET_FEATURE_FLAGS_DEFAULT: Record<string, boolean> = {
82
82
  __test_default__: false,
83
83
  };
84
84
 
85
+ class FakeIpcCallError extends Error {
86
+ readonly statusCode?: number;
87
+ readonly errorCode?: string;
88
+ readonly errorDetails?: unknown;
89
+
90
+ constructor(
91
+ message: string,
92
+ fields: {
93
+ statusCode?: number;
94
+ errorCode?: string;
95
+ errorDetails?: unknown;
96
+ } = {},
97
+ ) {
98
+ super(message);
99
+ this.name = "IpcCallError";
100
+ if (fields.statusCode !== undefined) this.statusCode = fields.statusCode;
101
+ if (fields.errorCode !== undefined) this.errorCode = fields.errorCode;
102
+ if (fields.errorDetails !== undefined)
103
+ this.errorDetails = fields.errorDetails;
104
+ }
105
+ }
106
+
85
107
  export function installGatewayIpcMock(): void {
86
108
  mock.module("@vellumai/gateway-client/ipc-client", () => ({
87
109
  ipcCall: async (
@@ -97,6 +119,7 @@ export function installGatewayIpcMock(): void {
97
119
  if (method === "get_feature_flags") return GET_FEATURE_FLAGS_DEFAULT;
98
120
  return undefined;
99
121
  },
122
+ IpcCallError: FakeIpcCallError,
100
123
  PersistentIpcClient: FakePersistentIpcClient,
101
124
  }));
102
125
  }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { AppDefinition } from "../memory/app-store.js";
4
+
5
+ let appsByConversation: AppDefinition[] = [];
6
+
7
+ const realStore = await import("../memory/app-store.js");
8
+ mock.module("../memory/app-store.js", () => ({
9
+ ...realStore,
10
+ listAppsByConversation: (_conversationId: string) => appsByConversation,
11
+ }));
12
+
13
+ const { resolveAppId, missingAppIdError } =
14
+ await import("../tools/apps/resolve-app-id.js");
15
+
16
+ function makeApp(id: string, updatedAt: number): AppDefinition {
17
+ return {
18
+ id,
19
+ name: id,
20
+ schemaJson: "{}",
21
+ htmlDefinition: "",
22
+ createdAt: updatedAt,
23
+ updatedAt,
24
+ };
25
+ }
26
+
27
+ describe("resolveAppId", () => {
28
+ test("returns an explicit non-empty app_id unchanged", () => {
29
+ appsByConversation = [makeApp("other", 1)];
30
+ expect(resolveAppId({ app_id: "explicit" }, "conv-1")).toBe("explicit");
31
+ });
32
+
33
+ test("falls back to the most-recently-updated conversation app when missing", () => {
34
+ // listAppsByConversation inherits listApps' updatedAt-descending order.
35
+ appsByConversation = [makeApp("newest", 30), makeApp("older", 10)];
36
+ expect(resolveAppId({}, "conv-1")).toBe("newest");
37
+ });
38
+
39
+ test("treats a blank app_id as missing", () => {
40
+ appsByConversation = [makeApp("active", 5)];
41
+ expect(resolveAppId({ app_id: " " }, "conv-1")).toBe("active");
42
+ });
43
+
44
+ test("returns null when no app_id is given and the conversation has no app", () => {
45
+ appsByConversation = [];
46
+ expect(resolveAppId({}, "conv-1")).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe("missingAppIdError", () => {
51
+ test("is an actionable error result", () => {
52
+ const result = missingAppIdError();
53
+ expect(result.isError).toBe(true);
54
+ expect(JSON.parse(result.content).error).toContain("app_create");
55
+ });
56
+ });
@@ -22,7 +22,6 @@ export const ALLOWED_PACKAGES: readonly string[] = [
22
22
  "lodash-es",
23
23
  "zod",
24
24
  "clsx",
25
- "lucide",
26
25
  ] as const;
27
26
 
28
27
  const INSTALL_TIMEOUT_MS = 10_000;
@@ -196,7 +196,7 @@ render(<App />, document.getElementById("app")!);
196
196
  2. **`file_write`** each real file, one per tool call, overwriting the placeholders and adding components.
197
197
  3. **`app_refresh`** ONCE at the end to compile.
198
198
 
199
- **Allowed packages** (esbuild-resolved, no CDN): `date-fns`, `chart.js`, `lodash-es`, `zod`, `clsx`, `lucide` (use `lucide`, NOT `lucide-react`).
199
+ **Allowed packages** (esbuild-resolved, no CDN): `date-fns`, `chart.js`, `lodash-es`, `zod`, `clsx`. For icons, write inline `<svg>` markup directly — no icon package is bundled.
200
200
 
201
201
  **Constraints:** Preact not React. No CDN imports. No external fonts/images (system fonts, inline CSS/SVG). Responsive only, no fixed-pixel widths. The WebView blocks navigation — `href` and form `action` don't work.
202
202
 
@@ -15,5 +15,10 @@ export async function run(
15
15
  setAppCommitMessage(context.conversationId, input.change_summary.trim());
16
16
  }
17
17
  const createInput: AppCreateInput = input as unknown as AppCreateInput;
18
- return executeAppCreate(createInput, appStore, context.proxyToolResolver);
18
+ return executeAppCreate(
19
+ createInput,
20
+ appStore,
21
+ context.proxyToolResolver,
22
+ context.conversationId,
23
+ );
19
24
  }
@@ -2,6 +2,10 @@ import { setAppCommitMessage } from "../../../../memory/app-git-service.js";
2
2
  import * as appStore from "../../../../memory/app-store.js";
3
3
  import type { AppGenerateIconInput } from "../../../../tools/apps/executors.js";
4
4
  import { executeAppGenerateIcon } from "../../../../tools/apps/executors.js";
5
+ import {
6
+ missingAppIdError,
7
+ resolveAppId,
8
+ } from "../../../../tools/apps/resolve-app-id.js";
5
9
  import type {
6
10
  ToolContext,
7
11
  ToolExecutionResult,
@@ -14,8 +18,10 @@ export async function run(
14
18
  if (typeof input.change_summary === "string" && input.change_summary.trim()) {
15
19
  setAppCommitMessage(context.conversationId, input.change_summary.trim());
16
20
  }
21
+ const appId = resolveAppId(input, context.conversationId);
22
+ if (!appId) return missingAppIdError();
17
23
  return executeAppGenerateIcon(
18
- input as unknown as AppGenerateIconInput,
24
+ { ...input, app_id: appId } as unknown as AppGenerateIconInput,
19
25
  appStore,
20
26
  );
21
27
  }
@@ -1,6 +1,10 @@
1
1
  import { setAppCommitMessage } from "../../../../memory/app-git-service.js";
2
2
  import * as appStore from "../../../../memory/app-store.js";
3
3
  import { executeAppRefresh } from "../../../../tools/apps/executors.js";
4
+ import {
5
+ missingAppIdError,
6
+ resolveAppId,
7
+ } from "../../../../tools/apps/resolve-app-id.js";
4
8
  import type {
5
9
  ToolContext,
6
10
  ToolExecutionResult,
@@ -13,5 +17,7 @@ export async function run(
13
17
  if (typeof input.change_summary === "string" && input.change_summary.trim()) {
14
18
  setAppCommitMessage(context.conversationId, input.change_summary.trim());
15
19
  }
16
- return executeAppRefresh({ app_id: input.app_id as string }, appStore);
20
+ const appId = resolveAppId(input, context.conversationId);
21
+ if (!appId) return missingAppIdError();
22
+ return executeAppRefresh({ app_id: appId }, appStore);
17
23
  }