@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 +1 -1
- package/src/__tests__/card-surface-data.test.ts +60 -0
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +3 -3
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +352 -0
- package/src/__tests__/dynamic-page-surface.test.ts +0 -94
- package/src/api/events/ui-surface-show.ts +8 -3
- package/src/api/index.ts +1 -0
- package/src/api/responses/conversation-message.ts +4 -0
- package/src/api/surfaces.ts +33 -0
- package/src/daemon/conversation-surfaces.ts +273 -18
- package/src/daemon/message-types/surfaces.ts +11 -20
- package/src/tools/ui-surface/definitions.ts +0 -43
package/package.json
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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: {
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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;
|