@vellumai/assistant 0.10.1-staging.3 → 0.10.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.1-staging.3",
3
+ "version": "0.10.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { CardSurfaceDataSchema } from "../api/surfaces.js";
4
+
5
+ // The wire keeps surface `data` opaque and the stream drops events that fail to
6
+ // parse, so the canonical card schema must never reject a real payload: every
7
+ // field is optional and unknown keys are stripped. The daemon's `ui_show`
8
+ // normalizer parses against this schema and logs the stripped keys — that is
9
+ // how unsupported shapes are surfaced rather than silently swallowed.
10
+ describe("CardSurfaceDataSchema", () => {
11
+ test("parses an empty object", () => {
12
+ expect(CardSurfaceDataSchema.safeParse({}).success).toBe(true);
13
+ });
14
+
15
+ test("parses a title-less card and strips unknown keys", () => {
16
+ const parsed = CardSurfaceDataSchema.safeParse({
17
+ body: "hi",
18
+ surfaceWidgetHint: "ignored",
19
+ });
20
+ expect(parsed.success).toBe(true);
21
+ if (parsed.success) {
22
+ expect(parsed.data).toEqual({ body: "hi" });
23
+ }
24
+ });
25
+
26
+ test("a body-less, title-only card is still valid (renders its title)", () => {
27
+ expect(CardSurfaceDataSchema.safeParse({ title: "Heads up" }).success).toBe(
28
+ true,
29
+ );
30
+ });
31
+
32
+ test("coerces primitive metadata values to strings", () => {
33
+ const parsed = CardSurfaceDataSchema.safeParse({
34
+ metadata: [
35
+ { label: "Docs", value: 12 },
36
+ { label: "Passed", value: true },
37
+ { label: "Status", value: "OK" },
38
+ ],
39
+ });
40
+ expect(parsed.success).toBe(true);
41
+ if (parsed.success) {
42
+ expect(parsed.data.metadata).toEqual([
43
+ { label: "Docs", value: "12" },
44
+ { label: "Passed", value: "true" },
45
+ { label: "Status", value: "OK" },
46
+ ]);
47
+ }
48
+ });
49
+
50
+ test("the schema's keys define what the normalizer supports", () => {
51
+ expect(Object.keys(CardSurfaceDataSchema.shape).sort()).toEqual([
52
+ "body",
53
+ "metadata",
54
+ "subtitle",
55
+ "template",
56
+ "templateData",
57
+ "title",
58
+ ]);
59
+ });
60
+ });
@@ -184,7 +184,7 @@ describe("activation moment emission from ui_show surface commits", () => {
184
184
  await surfaceProxyResolver(ctx, "ui_show", {
185
185
  surface_type: "card",
186
186
  title: "Inbox cleaned",
187
- data: { text: "Archived 1,240 emails" },
187
+ data: { body: "Archived 1,240 emails" },
188
188
  activation_moment: "first_wow_executed",
189
189
  });
190
190
 
@@ -210,7 +210,7 @@ describe("activation moment emission from ui_show surface commits", () => {
210
210
  await surfaceProxyResolver(ctx, "ui_show", {
211
211
  surface_type: "card",
212
212
  title: "Result",
213
- data: { text: "x" },
213
+ data: { body: "x" },
214
214
  activation_moment: "first_wow_executed",
215
215
  });
216
216
  expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
@@ -283,7 +283,7 @@ describe("activation moment emission from ui_show surface commits", () => {
283
283
  await surfaceProxyResolver(ctx, "ui_show", {
284
284
  surface_type: "card",
285
285
  title: "Start something",
286
- data: { text: "Kick off a draft" },
286
+ data: { body: "Kick off a draft" },
287
287
  actions: [{ id: "go", label: "Go", style: "primary" }],
288
288
  activation_moment: "moment_1",
289
289
  });
@@ -576,3 +576,355 @@ describe("task_progress surface compatibility", () => {
576
576
  expect(sent.some((m) => m.type === "ui_surface_show")).toBe(true);
577
577
  });
578
578
  });
579
+
580
+ describe("ui_show card content recovery", () => {
581
+ function shownCard(sent: ServerMessage[]): CardSurfaceData | undefined {
582
+ const show = sent.find(
583
+ (m): m is UiSurfaceShow => m.type === "ui_surface_show",
584
+ );
585
+ if (!show || show.surfaceType !== "card") return undefined;
586
+ return show.data;
587
+ }
588
+
589
+ test("recovers body from a copy_block-style `text` field", async () => {
590
+ const sent: ServerMessage[] = [];
591
+ const ctx = makeContext(sent);
592
+
593
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
594
+ surface_type: "card",
595
+ title: "Inbox cleaned",
596
+ data: { text: "Archived 1,240 emails" },
597
+ });
598
+
599
+ expect(result.isError).toBe(false);
600
+ const card = shownCard(sent);
601
+ expect(card?.body).toBe("Archived 1,240 emails");
602
+ // The alias key is not a card field; it must not survive on the surface.
603
+ expect((card as Record<string, unknown>).text).toBeUndefined();
604
+ });
605
+
606
+ test("recovers body from a confirmation-style `message` field", async () => {
607
+ const sent: ServerMessage[] = [];
608
+ const ctx = makeContext(sent);
609
+
610
+ await surfaceProxyResolver(ctx, "ui_show", {
611
+ surface_type: "card",
612
+ data: { title: "Heads up", message: "The server will restart." },
613
+ });
614
+
615
+ expect(shownCard(sent)?.body).toBe("The server will restart.");
616
+ });
617
+
618
+ test("recovers top-level subtitle and metadata into the card", async () => {
619
+ const sent: ServerMessage[] = [];
620
+ const ctx = makeContext(sent);
621
+
622
+ await surfaceProxyResolver(ctx, "ui_show", {
623
+ surface_type: "card",
624
+ subtitle: "saved just now",
625
+ metadata: [{ label: "Total", value: "$10" }],
626
+ data: { body: "Done" },
627
+ });
628
+
629
+ const card = shownCard(sent);
630
+ expect(card?.subtitle).toBe("saved just now");
631
+ expect(card?.metadata).toEqual([{ label: "Total", value: "$10" }]);
632
+ });
633
+
634
+ test("title-only card with actions renders with actions intact", async () => {
635
+ const sent: ServerMessage[] = [];
636
+ const ctx = makeContext(sent);
637
+
638
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
639
+ surface_type: "card",
640
+ title: "Restart the server?",
641
+ actions: [{ id: "yes", label: "Yes" }],
642
+ data: {},
643
+ });
644
+
645
+ expect(result.isError).toBe(false);
646
+ expect(shownCard(sent)?.title).toBe("Restart the server?");
647
+ const show = sent.find(
648
+ (m): m is UiSurfaceShow => m.type === "ui_surface_show",
649
+ )!;
650
+ expect(show.actions).toBeDefined();
651
+ expect(show.actions!.length).toBe(1);
652
+ expect(show.actions![0].label).toBe("Yes");
653
+ });
654
+
655
+ test("title-only card without actions renders without error", async () => {
656
+ const sent: ServerMessage[] = [];
657
+ const ctx = makeContext(sent);
658
+
659
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
660
+ surface_type: "card",
661
+ title: "Status update",
662
+ data: {},
663
+ });
664
+
665
+ expect(result.isError).toBe(false);
666
+ expect(shownCard(sent)?.title).toBe("Status update");
667
+ });
668
+
669
+ test("card with body and actions is interactive", async () => {
670
+ const sent: ServerMessage[] = [];
671
+ const ctx = makeContext(sent);
672
+
673
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
674
+ surface_type: "card",
675
+ title: "Confirm",
676
+ actions: [{ id: "ok", label: "OK" }],
677
+ data: { body: "Are you sure?" },
678
+ });
679
+
680
+ expect(result.isError).toBe(false);
681
+ const show = sent.find(
682
+ (m): m is UiSurfaceShow => m.type === "ui_surface_show",
683
+ )!;
684
+ expect(show.actions).toBeDefined();
685
+ expect(show.actions!.length).toBe(1);
686
+ });
687
+
688
+ // ── Body alias recovery from cross-surface keys ────────────────────
689
+
690
+ test("recovers body from choice/form-style `description` field", async () => {
691
+ const sent: ServerMessage[] = [];
692
+ const ctx = makeContext(sent);
693
+
694
+ await surfaceProxyResolver(ctx, "ui_show", {
695
+ surface_type: "card",
696
+ title: "Search results",
697
+ data: { description: "Found 12 matching documents." },
698
+ });
699
+
700
+ const card = shownCard(sent);
701
+ expect(card?.body).toBe("Found 12 matching documents.");
702
+ expect((card as Record<string, unknown>).description).toBeUndefined();
703
+ });
704
+
705
+ test("recovers body from work_result-style `summary` field", async () => {
706
+ const sent: ServerMessage[] = [];
707
+ const ctx = makeContext(sent);
708
+
709
+ await surfaceProxyResolver(ctx, "ui_show", {
710
+ surface_type: "card",
711
+ data: { title: "Report", summary: "All tests passed." },
712
+ });
713
+
714
+ expect(shownCard(sent)?.body).toBe("All tests passed.");
715
+ });
716
+
717
+ test("recovers body from confirmation-style `detail` field", async () => {
718
+ const sent: ServerMessage[] = [];
719
+ const ctx = makeContext(sent);
720
+
721
+ await surfaceProxyResolver(ctx, "ui_show", {
722
+ surface_type: "card",
723
+ data: { title: "Warning", detail: "This action cannot be undone." },
724
+ });
725
+
726
+ expect(shownCard(sent)?.body).toBe("This action cannot be undone.");
727
+ });
728
+
729
+ test("recovers body from top-level `description`", async () => {
730
+ const sent: ServerMessage[] = [];
731
+ const ctx = makeContext(sent);
732
+
733
+ await surfaceProxyResolver(ctx, "ui_show", {
734
+ surface_type: "card",
735
+ title: "Info",
736
+ description: "Top-level description text",
737
+ data: {},
738
+ });
739
+
740
+ expect(shownCard(sent)?.body).toBe("Top-level description text");
741
+ });
742
+
743
+ test("concatenates multiple body aliases when they co-occur", async () => {
744
+ const sent: ServerMessage[] = [];
745
+ const ctx = makeContext(sent);
746
+
747
+ await surfaceProxyResolver(ctx, "ui_show", {
748
+ surface_type: "card",
749
+ title: "Multi-alias",
750
+ data: {
751
+ description: "Found 12 documents.",
752
+ summary: "Search complete.",
753
+ detail: "Checked 3 sources.",
754
+ },
755
+ });
756
+
757
+ const body = shownCard(sent)?.body;
758
+ expect(body).toContain("Found 12 documents.");
759
+ expect(body).toContain("Search complete.");
760
+ expect(body).toContain("Checked 3 sources.");
761
+ });
762
+
763
+ // ── Title alias recovery ────────────────────────────────────────────
764
+
765
+ test("recovers title from `heading` alias", async () => {
766
+ const sent: ServerMessage[] = [];
767
+ const ctx = makeContext(sent);
768
+
769
+ await surfaceProxyResolver(ctx, "ui_show", {
770
+ surface_type: "card",
771
+ data: { heading: "Results", body: "Done." },
772
+ });
773
+
774
+ expect(shownCard(sent)?.title).toBe("Results");
775
+ });
776
+
777
+ test("recovers title from `header` alias", async () => {
778
+ const sent: ServerMessage[] = [];
779
+ const ctx = makeContext(sent);
780
+
781
+ await surfaceProxyResolver(ctx, "ui_show", {
782
+ surface_type: "card",
783
+ data: { header: "Status Update", body: "All good." },
784
+ });
785
+
786
+ expect(shownCard(sent)?.title).toBe("Status Update");
787
+ });
788
+
789
+ // ── Subtitle alias recovery ─────────────────────────────────────────
790
+
791
+ test("recovers subtitle from `subheading` alias", async () => {
792
+ const sent: ServerMessage[] = [];
793
+ const ctx = makeContext(sent);
794
+
795
+ await surfaceProxyResolver(ctx, "ui_show", {
796
+ surface_type: "card",
797
+ data: { title: "Alert", body: "Check this.", subheading: "Important" },
798
+ });
799
+
800
+ expect(shownCard(sent)?.subtitle).toBe("Important");
801
+ });
802
+
803
+ test("recovers subtitle from table-style `caption` alias", async () => {
804
+ const sent: ServerMessage[] = [];
805
+ const ctx = makeContext(sent);
806
+
807
+ await surfaceProxyResolver(ctx, "ui_show", {
808
+ surface_type: "card",
809
+ data: { title: "Table Summary", body: "Data below.", caption: "Q4 2024" },
810
+ });
811
+
812
+ expect(shownCard(sent)?.subtitle).toBe("Q4 2024");
813
+ });
814
+
815
+ // ── Alias precedence ───────────────────────────────────────────────
816
+
817
+ test("canonical `body` takes precedence over aliased `description`", async () => {
818
+ const sent: ServerMessage[] = [];
819
+ const ctx = makeContext(sent);
820
+
821
+ await surfaceProxyResolver(ctx, "ui_show", {
822
+ surface_type: "card",
823
+ data: { body: "Real body", description: "Should be ignored" },
824
+ });
825
+
826
+ expect(shownCard(sent)?.body).toBe("Real body");
827
+ });
828
+
829
+ test("card with recovered `description` and actions keeps actions (has content)", async () => {
830
+ const sent: ServerMessage[] = [];
831
+ const ctx = makeContext(sent);
832
+
833
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
834
+ surface_type: "card",
835
+ title: "Confirm",
836
+ actions: [{ id: "ok", label: "OK" }],
837
+ data: { description: "Proceed with deployment?" },
838
+ });
839
+
840
+ expect(result.isError).toBe(false);
841
+ const show = sent.find(
842
+ (m): m is UiSurfaceShow => m.type === "ui_surface_show",
843
+ )!;
844
+ expect(show.actions).toBeDefined();
845
+ expect(shownCard(sent)?.body).toBe("Proceed with deployment?");
846
+ });
847
+
848
+ test("recovers actions nested inside data when top-level actions is absent", async () => {
849
+ const sent: ServerMessage[] = [];
850
+ const ctx = makeContext(sent);
851
+
852
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
853
+ surface_type: "card",
854
+ title: "Confirm deployment",
855
+ data: {
856
+ body: "Deploy to production?",
857
+ actions: [{ id: "yes", label: "Yes" }],
858
+ },
859
+ });
860
+
861
+ expect(result.isError).toBe(false);
862
+ const show = sent.find(
863
+ (m): m is UiSurfaceShow => m.type === "ui_surface_show",
864
+ )!;
865
+ expect(show.actions).toBeDefined();
866
+ expect(show.actions!.length).toBe(1);
867
+ expect(show.actions![0].label).toBe("Yes");
868
+ });
869
+
870
+ test("top-level actions take precedence over data.actions", async () => {
871
+ const sent: ServerMessage[] = [];
872
+ const ctx = makeContext(sent);
873
+
874
+ await surfaceProxyResolver(ctx, "ui_show", {
875
+ surface_type: "card",
876
+ title: "Confirm",
877
+ actions: [{ id: "top", label: "Top-level" }],
878
+ data: {
879
+ body: "Which actions?",
880
+ actions: [{ id: "nested", label: "Nested" }],
881
+ },
882
+ });
883
+
884
+ const show = sent.find(
885
+ (m): m is UiSurfaceShow => m.type === "ui_surface_show",
886
+ )!;
887
+ expect(show.actions!.length).toBe(1);
888
+ expect(show.actions![0].label).toBe("Top-level");
889
+ });
890
+
891
+ test("genuinely empty card (no title, body, subtitle, metadata, template, or actions) is rejected", async () => {
892
+ const sent: ServerMessage[] = [];
893
+ const ctx = makeContext(sent);
894
+
895
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
896
+ surface_type: "card",
897
+ data: {},
898
+ });
899
+
900
+ expect(result.isError).toBe(true);
901
+ expect(result.content).toContain("requires content");
902
+ expect(sent.filter((m) => m.type === "ui_surface_show")).toHaveLength(0);
903
+ });
904
+
905
+ test("a card with a real body broadcasts unchanged", async () => {
906
+ const sent: ServerMessage[] = [];
907
+ const ctx = makeContext(sent);
908
+
909
+ await surfaceProxyResolver(ctx, "ui_show", {
910
+ surface_type: "card",
911
+ data: { title: "Plain", body: "hi" },
912
+ });
913
+
914
+ expect(shownCard(sent)?.body).toBe("hi");
915
+ });
916
+
917
+ test("a task_progress card with empty data broadcasts (template renders a shell)", async () => {
918
+ const sent: ServerMessage[] = [];
919
+ const ctx = makeContext(sent);
920
+
921
+ const result = await surfaceProxyResolver(ctx, "ui_show", {
922
+ surface_type: "card",
923
+ template: "task_progress",
924
+ templateData: { status: "in_progress", steps: [] },
925
+ });
926
+
927
+ expect(result.isError).toBe(false);
928
+ expect(shownCard(sent)?.template).toBe("task_progress");
929
+ });
930
+ });
@@ -257,100 +257,6 @@ describe("ui_show dynamic_page app substitute guard", () => {
257
257
  });
258
258
  });
259
259
 
260
- describe("ui_show empty card guard", () => {
261
- function makeCtx(onProxy: () => void) {
262
- return {
263
- conversationId: "conversation-123",
264
- workingDir: "/tmp",
265
- trustClass: "guardian" as const,
266
- proxyToolResolver: async () => {
267
- onProxy();
268
- return { content: "proxied", isError: false };
269
- },
270
- };
271
- }
272
-
273
- test("rejects a card carrying only a title and does not proxy", async () => {
274
- let proxied = false;
275
- const result = await uiShowTool.execute(
276
- {
277
- surface_type: "card",
278
- title: "Vellum Internal Usage app",
279
- activity: "Showing progress",
280
- data: {},
281
- },
282
- makeCtx(() => {
283
- proxied = true;
284
- }),
285
- );
286
-
287
- expect(result.isError).toBe(true);
288
- expect(result.content).toContain("card requires content");
289
- expect(proxied).toBe(false);
290
- });
291
-
292
- test("rejects a card with no content at all", async () => {
293
- let proxied = false;
294
- const result = await uiShowTool.execute(
295
- { surface_type: "card", data: {} },
296
- makeCtx(() => {
297
- proxied = true;
298
- }),
299
- );
300
-
301
- expect(result.isError).toBe(true);
302
- expect(proxied).toBe(false);
303
- });
304
-
305
- test("allows a card with a body", async () => {
306
- let proxied = false;
307
- const result = await uiShowTool.execute(
308
- { surface_type: "card", data: { title: "Plain", body: "hi" } },
309
- makeCtx(() => {
310
- proxied = true;
311
- }),
312
- );
313
-
314
- expect(result.isError).toBe(false);
315
- expect(proxied).toBe(true);
316
- });
317
-
318
- test("allows a task_progress card with empty data (template renders a shell)", async () => {
319
- let proxied = false;
320
- const result = await uiShowTool.execute(
321
- {
322
- surface_type: "card",
323
- template: "task_progress",
324
- templateData: { status: "in_progress", steps: [] },
325
- },
326
- makeCtx(() => {
327
- proxied = true;
328
- }),
329
- );
330
-
331
- expect(result.isError).toBe(false);
332
- expect(proxied).toBe(true);
333
- });
334
-
335
- test("allows an action-only card", async () => {
336
- let proxied = false;
337
- const result = await uiShowTool.execute(
338
- {
339
- surface_type: "card",
340
- title: "Confirm",
341
- actions: [{ id: "ok", label: "OK" }],
342
- data: {},
343
- },
344
- makeCtx(() => {
345
- proxied = true;
346
- }),
347
- );
348
-
349
- expect(result.isError).toBe(false);
350
- expect(proxied).toBe(true);
351
- });
352
- });
353
-
354
260
  describe("ui_update empty payload guard", () => {
355
261
  function makeCtx(onProxy: () => void) {
356
262
  return {
@@ -5,9 +5,14 @@
5
5
  * form, list, table, confirmation, dynamic_page, file_upload,
6
6
  * document_preview, task_preferences) inside the chat view. The
7
7
  * concrete `data` shape depends on `surfaceType` and is owned by the
8
- * surface-data subsystem in `daemon/message-types/surfaces.ts`; the
9
- * canonical schema treats `data` as opaque on the wire so this file
10
- * doesn't have to mirror eight nested-payload schemas.
8
+ * surface-data subsystem in `daemon/message-types/surfaces.ts`
9
+ * (`CardSurfaceDataSchema` et al.). `data` is intentionally opaque on the
10
+ * wire not for brevity, but because (1) this event is a member of the
11
+ * `type`-discriminated `AssistantEventSchema`, and (2) the stream parser
12
+ * drops any event that fails validation, so a strict per-`surfaceType`
13
+ * payload schema would silently vanish renderable-but-messy LLM surfaces.
14
+ * Consumers narrow `data` by parsing it with the canonical per-type schema
15
+ * (all-optional, so it never rejects a real surface) at their boundary.
11
16
  *
12
17
  * Lifecycle: a surface progresses `show` → (zero or more `update`s) →
13
18
  * (`dismiss` for cancellation OR `complete` with a `summary` /
package/src/api/index.ts CHANGED
@@ -471,6 +471,7 @@ export {
471
471
  type WorkflowLeaf,
472
472
  WorkflowLeafSchema,
473
473
  } from "./responses/workflow-journal.js";
474
+ export { type CardSurfaceData, CardSurfaceDataSchema } from "./surfaces.js";
474
475
 
475
476
  /**
476
477
  * Canonical SSE event schema for the assistant runtime.
@@ -225,6 +225,10 @@ export type ConversationMessageToolCall = z.infer<
225
225
  // Surface
226
226
  // ---------------------------------------------------------------------------
227
227
 
228
+ // Intentionally more permissive than the canonical SurfaceActionSchema in
229
+ // api/events/ui-surface-show.ts: the write-path schema uses z.enum for style
230
+ // so new surfaces only emit known values; this read-path schema uses z.string
231
+ // so historical surfaces with non-standard style values still parse.
228
232
  const SurfaceActionSchema = z.object({
229
233
  id: z.string(),
230
234
  label: z.string(),
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Canonical surface-data wire payloads.
3
+ *
4
+ * The `ui_surface_*` events and the conversation-message response all carry a
5
+ * surface `data` object whose shape depends on `surfaceType`. The wire keeps
6
+ * `data` opaque (`z.record`) — see `events/ui-surface-show.ts` for why — so
7
+ * consumers narrow it by parsing with the canonical per-type schema here. The
8
+ * schemas are deliberately tolerant (every field optional, Zod strip mode): a
9
+ * parse miss makes a renderable surface silently vanish, so they must never
10
+ * reject a real payload. The schema also defines what the daemon's `ui_show`
11
+ * normalizer *supports* — anything the model sends outside these fields is
12
+ * dropped (and logged) there, which is how we learn the shapes to recover.
13
+ *
14
+ * Card is the first surface type migrated to a canonical schema; the remaining
15
+ * types still live as hand-written interfaces in
16
+ * `daemon/message-types/surfaces.ts` pending migration.
17
+ */
18
+
19
+ import { z } from "zod";
20
+
21
+ export const CardSurfaceDataSchema = z.object({
22
+ title: z.string().optional(),
23
+ subtitle: z.string().optional(),
24
+ body: z.string().optional(),
25
+ metadata: z
26
+ .array(z.object({ label: z.coerce.string(), value: z.coerce.string() }))
27
+ .optional(),
28
+ /** Optional template name for specialized rendering (e.g. "weather_forecast"). */
29
+ template: z.string().optional(),
30
+ /** Arbitrary data consumed by the template renderer. Shape depends on template. */
31
+ templateData: z.record(z.string(), z.unknown()).optional(),
32
+ });
33
+ export type CardSurfaceData = z.infer<typeof CardSurfaceDataSchema>;
@@ -1,5 +1,9 @@
1
+ import * as Sentry from "@sentry/node";
1
2
  import { v4 as uuid } from "uuid";
3
+ import { z } from "zod";
2
4
 
5
+ import { SurfaceActionSchema } from "../api/events/ui-surface-show.js";
6
+ import { CardSurfaceDataSchema } from "../api/surfaces.js";
3
7
  import { isActivationSession } from "../memory/activation-session-store.js";
4
8
  import {
5
9
  addAppConversationId,
@@ -70,6 +74,16 @@ import type { TrustContext } from "./trust-context.js";
70
74
 
71
75
  const log = getLogger("conversation-surfaces");
72
76
 
77
+ // Tolerant variant of SurfaceActionSchema for parsing raw model output.
78
+ // The canonical schema rejects unknown style values; this one coerces them
79
+ // to "secondary" so a single mistyped style doesn't drop all actions.
80
+ const ModelActionSchema = SurfaceActionSchema.extend({
81
+ style: z
82
+ .enum(["primary", "secondary", "destructive"])
83
+ .catch("secondary")
84
+ .optional(),
85
+ });
86
+
73
87
  const MAX_UNDO_DEPTH = 10;
74
88
 
75
89
  /**
@@ -472,6 +486,27 @@ function normalizeDynamicPageShowData(
472
486
  return normalized as unknown as DynamicPageSurfaceData;
473
487
  }
474
488
 
489
+ /** First entry that is a non-empty (trimmed) string, else undefined. */
490
+ function firstNonEmptyString(values: unknown[]): string | undefined {
491
+ for (const value of values) {
492
+ if (typeof value === "string" && value.trim().length > 0) {
493
+ return value;
494
+ }
495
+ }
496
+ return undefined;
497
+ }
498
+
499
+ /** All non-empty (trimmed) strings from the values list. */
500
+ function allNonEmptyStrings(values: unknown[]): string[] {
501
+ const result: string[] = [];
502
+ for (const value of values) {
503
+ if (typeof value === "string" && value.trim().length > 0) {
504
+ result.push(value);
505
+ }
506
+ }
507
+ return result;
508
+ }
509
+
475
510
  function normalizeCardShowData(
476
511
  input: Record<string, unknown>,
477
512
  rawData: Record<string, unknown>,
@@ -507,6 +542,113 @@ function normalizeCardShowData(
507
542
  normalized.body = input.body;
508
543
  }
509
544
 
545
+ // The model sees every surface type's schema in the ui_show tool description,
546
+ // so it frequently borrows keys from sibling surfaces when emitting a card.
547
+ // Recover those into the canonical card fields, checking both data-level and
548
+ // top-level (input) placement. Multiple matches are concatenated (body) or
549
+ // first-wins (title/subtitle); all alias keys are deleted afterward so they
550
+ // don't appear as droppedKeys noise.
551
+ //
552
+ // body aliases: copy_block's `text`, confirmation's `message`, generic
553
+ // `content`, and cross-surface `description` (choice/form/oauth/work_result/
554
+ // dynamic_page — 5 types use it), work_result's `summary`, confirmation's
555
+ // `detail`.
556
+ const bodyAliasKeys = [
557
+ "text",
558
+ "message",
559
+ "content",
560
+ "description",
561
+ "summary",
562
+ "detail",
563
+ ] as const;
564
+ if (typeof normalized.body !== "string" || normalized.body.trim() === "") {
565
+ const candidates = allNonEmptyStrings(
566
+ bodyAliasKeys.map((k) => {
567
+ const dataVal = normalized[k];
568
+ if (typeof dataVal === "string" && dataVal.trim().length > 0)
569
+ return dataVal;
570
+ return input[k];
571
+ }),
572
+ );
573
+ if (candidates.length > 0) {
574
+ // Temporary: concatenate all matching aliases so no content is lost.
575
+ // A future pass should define per-alias semantic roles (e.g. summary
576
+ // as a subtitle, detail as supplementary) once production telemetry
577
+ // reveals which combinations actually occur.
578
+ normalized.body = candidates.join("\n\n");
579
+ const usedAliases = bodyAliasKeys.filter(
580
+ (k) =>
581
+ (typeof normalized[k] === "string" &&
582
+ (normalized[k] as string).trim().length > 0) ||
583
+ (typeof input[k] === "string" &&
584
+ (input[k] as string).trim().length > 0),
585
+ );
586
+ Sentry.addBreadcrumb({
587
+ category: "card-normalization",
588
+ message: `alias recovery: ${usedAliases.join(", ")} → body`,
589
+ level: "info",
590
+ data: { usedAliases, candidateCount: candidates.length },
591
+ });
592
+ }
593
+ }
594
+ for (const key of bodyAliasKeys) {
595
+ delete normalized[key];
596
+ }
597
+
598
+ // title aliases: natural synonyms the model reaches for when it doesn't
599
+ // use `title` verbatim.
600
+ const titleAliasKeys = ["heading", "header", "name"] as const;
601
+ if (typeof normalized.title !== "string" || normalized.title.trim() === "") {
602
+ const aliased = firstNonEmptyString([
603
+ ...titleAliasKeys.map((k) => normalized[k]),
604
+ ...titleAliasKeys.map((k) => input[k]),
605
+ ]);
606
+ if (aliased !== undefined) {
607
+ normalized.title = aliased;
608
+ Sentry.addBreadcrumb({
609
+ category: "card-normalization",
610
+ message: `alias recovery: title`,
611
+ level: "info",
612
+ });
613
+ }
614
+ }
615
+ for (const key of titleAliasKeys) {
616
+ delete normalized[key];
617
+ }
618
+
619
+ // subtitle aliases: table's `caption`, natural synonym `subheading`.
620
+ if (
621
+ typeof normalized.subtitle !== "string" &&
622
+ typeof input.subtitle === "string"
623
+ ) {
624
+ normalized.subtitle = input.subtitle;
625
+ }
626
+ const subtitleAliasKeys = ["subheading", "caption"] as const;
627
+ if (
628
+ typeof normalized.subtitle !== "string" ||
629
+ normalized.subtitle.trim() === ""
630
+ ) {
631
+ const aliased = firstNonEmptyString([
632
+ ...subtitleAliasKeys.map((k) => normalized[k]),
633
+ ...subtitleAliasKeys.map((k) => input[k]),
634
+ ]);
635
+ if (aliased !== undefined) {
636
+ normalized.subtitle = aliased;
637
+ Sentry.addBreadcrumb({
638
+ category: "card-normalization",
639
+ message: `alias recovery: subtitle`,
640
+ level: "info",
641
+ });
642
+ }
643
+ }
644
+ for (const key of subtitleAliasKeys) {
645
+ delete normalized[key];
646
+ }
647
+
648
+ if (!Array.isArray(normalized.metadata) && Array.isArray(input.metadata)) {
649
+ normalized.metadata = input.metadata;
650
+ }
651
+
510
652
  // task_progress cards: additional fallbacks for title from templateData.
511
653
  if (
512
654
  normalized.template === "task_progress" &&
@@ -533,7 +675,40 @@ function normalizeCardShowData(
533
675
  ensureTaskProgressTemplateData(normalized);
534
676
  }
535
677
 
536
- return normalized as unknown as CardSurfaceData;
678
+ // Parse, don't assert. The old `as unknown as CardSurfaceData` accepted any
679
+ // shape, so anything the model nested under an unmodelled key was carried
680
+ // through unread. Parsing draws the boundary; the dropped-key log surfaces
681
+ // the shapes we still don't recover, so the recovery list above can grow from
682
+ // real traffic rather than guesswork.
683
+ const droppedKeys = Object.keys(normalized).filter(
684
+ (key) => !(key in CardSurfaceDataSchema.shape),
685
+ );
686
+ if (droppedKeys.length > 0) {
687
+ log.warn(
688
+ { droppedKeys },
689
+ "ui_show card data carried keys the card contract does not model; their content will not render",
690
+ );
691
+ Sentry.addBreadcrumb({
692
+ category: "card-normalization",
693
+ message: `dropped keys: ${droppedKeys.join(", ")}`,
694
+ level: "warning",
695
+ data: { droppedKeys },
696
+ });
697
+ }
698
+ const parsed = CardSurfaceDataSchema.safeParse(normalized);
699
+ if (parsed.success) {
700
+ return parsed.data;
701
+ }
702
+ log.warn(
703
+ { issues: parsed.error.issues },
704
+ "ui_show card data failed CardSurfaceDataSchema; rendering only the fields that validated",
705
+ );
706
+ return CardSurfaceDataSchema.parse({
707
+ title: typeof normalized.title === "string" ? normalized.title : undefined,
708
+ subtitle:
709
+ typeof normalized.subtitle === "string" ? normalized.subtitle : undefined,
710
+ body: typeof normalized.body === "string" ? normalized.body : undefined,
711
+ });
537
712
  }
538
713
 
539
714
  function normalizeTaskProgressCardPatch(
@@ -2693,9 +2868,17 @@ export async function surfaceProxyResolver(
2693
2868
  const surfaceType = input.surface_type as SurfaceType;
2694
2869
  const title = typeof input.title === "string" ? input.title : undefined;
2695
2870
  const rawData = isPlainObject(input.data) ? input.data : {};
2696
- const data = (
2871
+ // Each surface type that has a canonical Zod schema gets parsed through it;
2872
+ // the rest pass through raw until migrated (LUM-2134 scope). The per-type
2873
+ // normalizers validate+recover; the union cast at the end is only for the
2874
+ // unmigrated branches that still return hand-written interfaces.
2875
+ const cardData =
2697
2876
  surfaceType === "card"
2698
2877
  ? normalizeCardShowData(input, rawData)
2878
+ : undefined;
2879
+ const data: SurfaceData =
2880
+ cardData !== undefined
2881
+ ? cardData
2699
2882
  : surfaceType === "choice"
2700
2883
  ? normalizeChoiceShowData(rawData)
2701
2884
  : surfaceType === "copy_block"
@@ -2704,21 +2887,43 @@ export async function surfaceProxyResolver(
2704
2887
  ? normalizeOAuthConnectShowData(rawData)
2705
2888
  : surfaceType === "dynamic_page"
2706
2889
  ? normalizeDynamicPageShowData(input, rawData)
2707
- : rawData
2708
- ) as SurfaceData;
2709
- const inputActions = input.actions as
2710
- | Array<{
2711
- id: string;
2712
- label: string;
2713
- style?: string;
2714
- data?: Record<string, unknown>;
2715
- }>
2716
- | undefined;
2890
+ : (rawData as SurfaceData);
2891
+ // Parse actions through the schema instead of typecasting raw model output.
2892
+ // The model may place actions inside `data` instead of the top-level
2893
+ // `actions` param — recover them so they aren't silently dropped.
2894
+ const rawActions = Array.isArray(input.actions)
2895
+ ? input.actions
2896
+ : Array.isArray(rawData.actions)
2897
+ ? rawData.actions
2898
+ : undefined;
2899
+ let inputActions: z.infer<typeof ModelActionSchema>[] | undefined;
2900
+ if (rawActions) {
2901
+ const valid: z.infer<typeof ModelActionSchema>[] = [];
2902
+ for (const raw of rawActions) {
2903
+ const result = ModelActionSchema.safeParse(raw);
2904
+ if (result.success) {
2905
+ valid.push(result.data);
2906
+ } else {
2907
+ Sentry.addBreadcrumb({
2908
+ category: "card-normalization",
2909
+ message: "action parse failure (individual)",
2910
+ level: "warning",
2911
+ data: {
2912
+ issuePaths: result.error.issues.map((i) => i.path.join(".")),
2913
+ keys:
2914
+ typeof raw === "object" && raw !== null
2915
+ ? Object.keys(raw)
2916
+ : [typeof raw],
2917
+ },
2918
+ });
2919
+ }
2920
+ }
2921
+ inputActions = valid.length > 0 ? valid : undefined;
2922
+ }
2717
2923
  const actions =
2718
2924
  surfaceType === "choice"
2719
2925
  ? buildChoiceActions(data as ChoiceSurfaceData)
2720
2926
  : inputActions;
2721
- // Interactive surfaces default to awaiting user action.
2722
2927
  const hasActions = Array.isArray(actions) && actions.length > 0;
2723
2928
  if (surfaceType === "choice" && !hasActions) {
2724
2929
  return {
@@ -2727,6 +2932,39 @@ export async function surfaceProxyResolver(
2727
2932
  isError: true,
2728
2933
  };
2729
2934
  }
2935
+ if (cardData !== undefined) {
2936
+ const hasTitle =
2937
+ (typeof title === "string" && title.trim().length > 0) ||
2938
+ (typeof cardData.title === "string" &&
2939
+ cardData.title.trim().length > 0);
2940
+ const hasBody =
2941
+ typeof cardData.body === "string" && cardData.body.trim().length > 0;
2942
+ const hasSubtitle =
2943
+ typeof cardData.subtitle === "string" &&
2944
+ cardData.subtitle.trim().length > 0;
2945
+ const hasMetadata =
2946
+ Array.isArray(cardData.metadata) && cardData.metadata.length > 0;
2947
+ const hasTemplate = typeof cardData.template === "string";
2948
+ if (
2949
+ !hasTitle &&
2950
+ !hasBody &&
2951
+ !hasSubtitle &&
2952
+ !hasMetadata &&
2953
+ !hasTemplate &&
2954
+ !hasActions
2955
+ ) {
2956
+ Sentry.addBreadcrumb({
2957
+ category: "card-normalization",
2958
+ message: "empty card rejected",
2959
+ level: "warning",
2960
+ });
2961
+ return {
2962
+ content:
2963
+ "Error: ui_show card requires content — provide `data.body`, a `template` (e.g. task_progress with steps), `data.metadata`, `data.subtitle`, a `title`, or `actions`. The surface was not displayed because it carried no renderable content. Resend ui_show with populated card content.",
2964
+ isError: true,
2965
+ };
2966
+ }
2967
+ }
2730
2968
  const oauthProviderKey =
2731
2969
  surfaceType === "oauth_connect"
2732
2970
  ? (data as unknown as Record<string, unknown>).providerKey
@@ -2741,6 +2979,7 @@ export async function surfaceProxyResolver(
2741
2979
  isError: true,
2742
2980
  };
2743
2981
  }
2982
+
2744
2983
  const isInteractive =
2745
2984
  surfaceType === "card"
2746
2985
  ? hasActions
@@ -2777,10 +3016,7 @@ export async function surfaceProxyResolver(
2777
3016
  const mappedActions = actions?.map((a) => ({
2778
3017
  id: a.id,
2779
3018
  label: a.label,
2780
- style: (a.style ?? "secondary") as
2781
- | "primary"
2782
- | "secondary"
2783
- | "destructive",
3019
+ style: a.style ?? "secondary",
2784
3020
  ...(a.data ? { data: a.data } : {}),
2785
3021
  }));
2786
3022
 
@@ -2896,7 +3132,26 @@ export async function surfaceProxyResolver(
2896
3132
  const currentHtml = (stored.data as DynamicPageSurfaceData).html;
2897
3133
  pushUndoState(ctx.surfaceUndoStacks, surfaceId, currentHtml);
2898
3134
  }
2899
- mergedData = { ...stored.data, ...patch } as SurfaceData;
3135
+ const rawMerged = { ...stored.data, ...patch };
3136
+ if (stored.surfaceType === "card") {
3137
+ // Validate the merged card data through the canonical schema so
3138
+ // malformed patches (e.g. metadata as a string) are caught here
3139
+ // instead of crashing the client's safeParse.
3140
+ const parsed = CardSurfaceDataSchema.safeParse(rawMerged);
3141
+ mergedData = parsed.success
3142
+ ? parsed.data
3143
+ : (CardSurfaceDataSchema.safeParse(stored.data).data ?? {});
3144
+ if (!parsed.success) {
3145
+ log.warn(
3146
+ { surfaceId, issues: parsed.error.issues },
3147
+ "ui_update card patch produced invalid merged data; reverting to stored data",
3148
+ );
3149
+ }
3150
+ } else {
3151
+ // Other surface types lack canonical Zod schemas (LUM-2134 scope).
3152
+ // The raw merge is the best we can do until they're migrated.
3153
+ mergedData = rawMerged as SurfaceData;
3154
+ }
2900
3155
  stored.data = mergedData;
2901
3156
  } else {
2902
3157
  mergedData = patch as unknown as SurfaceData;
@@ -1,6 +1,16 @@
1
1
  // Surface types, UI surface lifecycle messages.
2
2
 
3
- import { z } from "zod";
3
+ import {
4
+ type CardSurfaceData,
5
+ CardSurfaceDataSchema,
6
+ } from "../../api/surfaces.js";
7
+
8
+ // Surface `data` shapes are wire payloads owned by `@vellumai/assistant-api`.
9
+ // Card is migrated (canonical Zod schema); the remaining types below are still
10
+ // hand-written interfaces pending migration. Re-exported so the daemon's
11
+ // surface protocol barrel (`message-protocol.ts`) keeps surfacing them to
12
+ // daemon consumers under their canonical names.
13
+ export { type CardSurfaceData, CardSurfaceDataSchema };
4
14
 
5
15
  // === Surface type definitions ===
6
16
 
@@ -37,25 +47,6 @@ export interface SurfaceAction {
37
47
  data?: Record<string, unknown>;
38
48
  }
39
49
 
40
- /**
41
- * Card surface data. Defined as a Zod schema so the type is derived (not
42
- * hand-maintained) and the seed-content-block schema can compose it directly
43
- * instead of treating card `data` as an opaque record.
44
- */
45
- export const CardSurfaceDataSchema = z.object({
46
- title: z.string(),
47
- subtitle: z.string().optional(),
48
- body: z.string(),
49
- metadata: z
50
- .array(z.object({ label: z.string(), value: z.string() }))
51
- .optional(),
52
- /** Optional template name for specialized rendering (e.g. "weather_forecast"). */
53
- template: z.string().optional(),
54
- /** Arbitrary data consumed by the template renderer. Shape depends on template. */
55
- templateData: z.record(z.string(), z.unknown()).optional(),
56
- });
57
- export type CardSurfaceData = z.infer<typeof CardSurfaceDataSchema>;
58
-
59
50
  export interface ChoiceOption {
60
51
  id: string;
61
52
  title: string;
@@ -45,14 +45,6 @@ function proxyExecute(toolName: string) {
45
45
  };
46
46
  }
47
47
 
48
- if (toolName === "ui_show" && isEmptyCard(input)) {
49
- return {
50
- content:
51
- "Error: ui_show card requires content — provide `data.body`, a `template` (e.g. task_progress with steps), `data.metadata`, or `actions`. The surface was not displayed because it carried only a title, which renders as a blank box. Resend ui_show with populated card content.",
52
- isError: true,
53
- };
54
- }
55
-
56
48
  if (toolName === "ui_show" && isDynamicPageAppSubstitute(input)) {
57
49
  return {
58
50
  content:
@@ -168,41 +160,6 @@ function isEmptyDynamicPage(input: Record<string, unknown>): boolean {
168
160
  return typeof html !== "string" || html.trim().length === 0;
169
161
  }
170
162
 
171
- /**
172
- * A `card` ui_show carrying no renderable content — only a title (or nothing)
173
- * — renders as a blank bordered box. A declared `template` (task_progress,
174
- * weather_forecast, …) renders its own shell, and `body`/`subtitle`/`metadata`/
175
- * `actions` are real content; any of those passes. The model places these
176
- * either nested in `data` or at the top level, so both are checked. Title is
177
- * intentionally not content: a title-only card is the blank box.
178
- */
179
- function isEmptyCard(input: Record<string, unknown>): boolean {
180
- if (input.surface_type !== "card") {
181
- return false;
182
- }
183
- const data = asRecord(input.data) ?? {};
184
-
185
- const template =
186
- nonEmptyString(input.template) ?? nonEmptyString(data.template);
187
- if (template) {
188
- return false;
189
- }
190
-
191
- const hasBody = !!(nonEmptyString(input.body) ?? nonEmptyString(data.body));
192
- const hasSubtitle = !!nonEmptyString(data.subtitle);
193
- const hasMetadata = Array.isArray(data.metadata) && data.metadata.length > 0;
194
- const actions = input.actions ?? data.actions;
195
- const hasActions = Array.isArray(actions) && actions.length > 0;
196
-
197
- return !(hasBody || hasSubtitle || hasMetadata || hasActions);
198
- }
199
-
200
- function nonEmptyString(value: unknown): string | undefined {
201
- return typeof value === "string" && value.trim().length > 0
202
- ? value
203
- : undefined;
204
- }
205
-
206
163
  function isDynamicPageAppSubstitute(input: Record<string, unknown>): boolean {
207
164
  if (input.surface_type !== "dynamic_page") {
208
165
  return false;