@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.
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
- package/openapi.yaml +99 -0
- package/package.json +1 -1
- package/src/__tests__/app-compiler.test.ts +7 -1
- package/src/__tests__/app-executors.test.ts +43 -0
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
- package/src/__tests__/dynamic-page-surface.test.ts +94 -0
- package/src/__tests__/mock-gateway-ipc.ts +23 -0
- package/src/__tests__/resolve-app-id.test.ts +56 -0
- package/src/bundler/package-resolver.ts +0 -1
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
- package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
- package/src/daemon/conversation-surfaces.ts +92 -3
- package/src/notifications/home-feed-side-effect.ts +27 -10
- package/src/runtime/channel-invite-transports/telegram.ts +6 -5
- package/src/runtime/channel-invite-transports/voice.ts +2 -2
- package/src/runtime/channel-invite-types.ts +4 -2
- package/src/runtime/channel-retry-sweep.ts +19 -41
- package/src/runtime/finalize-event-delivery.ts +72 -0
- package/src/runtime/routes/channel-delivery-routes.ts +11 -7
- package/src/runtime/routes/channel-route-definitions.ts +3 -0
- package/src/runtime/routes/inbound-message-handler.ts +13 -12
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +12 -36
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
- package/src/schedule/schedule-store.ts +30 -0
- package/src/tools/apps/executors.ts +23 -0
- package/src/tools/apps/resolve-app-id.ts +42 -0
- 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
|
@@ -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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
20
|
+
const appId = resolveAppId(input, context.conversationId);
|
|
21
|
+
if (!appId) return missingAppIdError();
|
|
22
|
+
return executeAppRefresh({ app_id: appId }, appStore);
|
|
17
23
|
}
|