@vellumai/assistant 0.10.2-dev.202606241651.2d2b40d → 0.10.2-dev.202606241743.799fce7
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__/mtime-cache.test.ts +146 -5
- package/src/__tests__/workspace-migration-remove-hooks.test.ts +14 -35
- package/src/daemon/conversation-surfaces.ts +85 -36
- package/src/hooks/hook-loader.ts +341 -0
- package/src/plugins/mtime-cache.ts +76 -296
- package/src/plugins/surface-import.ts +121 -0
- package/src/workspace/migrations/048-remove-workspace-hooks.ts +14 -66
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* cache miss (changed mtime → re-import), plugin deletion (eviction),
|
|
8
8
|
* and hook collection across multiple plugins.
|
|
9
9
|
*/
|
|
10
|
-
import { mkdirSync, rmSync, utimesSync,writeFileSync } from "node:fs";
|
|
10
|
+
import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import {
|
|
@@ -19,8 +19,8 @@ import {
|
|
|
19
19
|
test,
|
|
20
20
|
} from "bun:test";
|
|
21
21
|
|
|
22
|
+
import { _inspectHookCacheForTests } from "../hooks/hook-loader.js";
|
|
22
23
|
import {
|
|
23
|
-
_inspectHookCacheForTests,
|
|
24
24
|
_inspectToolCacheForTests,
|
|
25
25
|
getCachedUserTools,
|
|
26
26
|
getUserHooksFor,
|
|
@@ -62,7 +62,15 @@ function writeHook(dir: string, hookName: string, body: string): void {
|
|
|
62
62
|
function writeInstallMeta(dir: string, installedAt: string): void {
|
|
63
63
|
writeFileSync(
|
|
64
64
|
join(dir, "install-meta.json"),
|
|
65
|
-
JSON.stringify(
|
|
65
|
+
JSON.stringify(
|
|
66
|
+
{
|
|
67
|
+
name: "test",
|
|
68
|
+
installedAt,
|
|
69
|
+
source: { kind: "github", owner: "test", repo: "test", ref: "main" },
|
|
70
|
+
},
|
|
71
|
+
null,
|
|
72
|
+
2,
|
|
73
|
+
),
|
|
66
74
|
);
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -72,6 +80,20 @@ function writeTool(dir: string, toolName: string, body: string): void {
|
|
|
72
80
|
writeFileSync(join(toolsDir, `${toolName}.ts`), body);
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
/** The standalone workspace hooks directory (`<workspace>/hooks/`). */
|
|
84
|
+
const WORKSPACE_HOOKS_DIR = join(ROOT, "hooks");
|
|
85
|
+
|
|
86
|
+
function ensureWorkspaceHooksDir(): void {
|
|
87
|
+
rmSync(WORKSPACE_HOOKS_DIR, { recursive: true, force: true });
|
|
88
|
+
mkdirSync(WORKSPACE_HOOKS_DIR, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Write a standalone hook file directly under `<workspace>/hooks/`. */
|
|
92
|
+
function writeWorkspaceHook(hookName: string, body: string): void {
|
|
93
|
+
mkdirSync(WORKSPACE_HOOKS_DIR, { recursive: true });
|
|
94
|
+
writeFileSync(join(WORKSPACE_HOOKS_DIR, `${hookName}.ts`), body);
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
/**
|
|
76
98
|
* Bump a file's mtime forward by ~2 seconds so the mtime cache detects a
|
|
77
99
|
* change. Using utimesSync avoids race conditions with filesystem mtime
|
|
@@ -97,6 +119,7 @@ beforeAll(() => {
|
|
|
97
119
|
|
|
98
120
|
beforeEach(() => {
|
|
99
121
|
ensurePluginsDir();
|
|
122
|
+
ensureWorkspaceHooksDir();
|
|
100
123
|
resetPluginCacheForTests();
|
|
101
124
|
});
|
|
102
125
|
|
|
@@ -338,7 +361,9 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
338
361
|
expect(hooks).toHaveLength(2);
|
|
339
362
|
|
|
340
363
|
// beta was installed earlier (Jan 1) so it should come first.
|
|
341
|
-
const results = hooks.map((fn) =>
|
|
364
|
+
const results = hooks.map((fn) =>
|
|
365
|
+
(fn as unknown as () => { tag: string })(),
|
|
366
|
+
);
|
|
342
367
|
expect(results[0]!.tag).toBe("beta");
|
|
343
368
|
expect(results[1]!.tag).toBe("alpha");
|
|
344
369
|
});
|
|
@@ -368,8 +393,124 @@ describe("plugin mtime cache (per-surface)", () => {
|
|
|
368
393
|
const hooks = await getUserHooksFor("user-prompt-submit");
|
|
369
394
|
expect(hooks).toHaveLength(2);
|
|
370
395
|
|
|
371
|
-
const results = hooks.map((fn) =>
|
|
396
|
+
const results = hooks.map((fn) =>
|
|
397
|
+
(fn as unknown as () => { tag: string })(),
|
|
398
|
+
);
|
|
372
399
|
expect(results[0]!.tag).toBe("dated");
|
|
373
400
|
expect(results[1]!.tag).toBe("undated");
|
|
374
401
|
});
|
|
375
402
|
});
|
|
403
|
+
|
|
404
|
+
describe("workspace hooks (<workspace>/hooks/)", () => {
|
|
405
|
+
test("getUserHooksFor loads a standalone workspace hook", async () => {
|
|
406
|
+
writeWorkspaceHook(
|
|
407
|
+
"user-prompt-submit",
|
|
408
|
+
`export default () => ({ ws: 1 });`,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
await populateCacheAtBoot();
|
|
412
|
+
|
|
413
|
+
const hooks = await getUserHooksFor("user-prompt-submit");
|
|
414
|
+
expect(hooks).toHaveLength(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("workspace hooks load even when no plugins directory exists", async () => {
|
|
418
|
+
rmSync(PLUGINS_DIR, { recursive: true, force: true });
|
|
419
|
+
writeWorkspaceHook("post-tool-use", `export default () => ({ ws: 1 });`);
|
|
420
|
+
|
|
421
|
+
await populateCacheAtBoot();
|
|
422
|
+
|
|
423
|
+
const hooks = await getUserHooksFor("post-tool-use");
|
|
424
|
+
expect(hooks).toHaveLength(1);
|
|
425
|
+
expect(getCachedUserTools()).toHaveLength(0);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// NB: each test below uses a distinct hook event name so the workspace
|
|
429
|
+
// hook file path is unique. Bun caches dynamic import() by URL and does not
|
|
430
|
+
// bust on content change, so reusing `<workspace>/hooks/<name>.ts` across
|
|
431
|
+
// tests would return a stale module (the existing plugin tests dodge this
|
|
432
|
+
// by using a fresh plugin directory per test).
|
|
433
|
+
test("plugin hooks run before the workspace hook for the same event", async () => {
|
|
434
|
+
const dir = freshPluginDir("ordering-plugin");
|
|
435
|
+
writePackageJson(dir, { ...SIMPLE_PKG, name: "ordering-plugin" });
|
|
436
|
+
writeHook(
|
|
437
|
+
dir,
|
|
438
|
+
"pre-model-call",
|
|
439
|
+
`export default () => ({ tag: "plugin" });`,
|
|
440
|
+
);
|
|
441
|
+
writeWorkspaceHook(
|
|
442
|
+
"pre-model-call",
|
|
443
|
+
`export default () => ({ tag: "workspace" });`,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
await populateCacheAtBoot();
|
|
447
|
+
|
|
448
|
+
const hooks = await getUserHooksFor("pre-model-call");
|
|
449
|
+
expect(hooks).toHaveLength(2);
|
|
450
|
+
const results = hooks.map((fn) =>
|
|
451
|
+
(fn as unknown as () => { tag: string })(),
|
|
452
|
+
);
|
|
453
|
+
expect(results[0]!.tag).toBe("plugin");
|
|
454
|
+
expect(results[1]!.tag).toBe("workspace");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("editing a workspace hook file triggers re-import", async () => {
|
|
458
|
+
const hookFile = join(WORKSPACE_HOOKS_DIR, "post-model-call.ts");
|
|
459
|
+
writeWorkspaceHook("post-model-call", `export default () => ({ v: 1 });`);
|
|
460
|
+
|
|
461
|
+
await populateCacheAtBoot();
|
|
462
|
+
|
|
463
|
+
const before = _inspectHookCacheForTests().find((c) =>
|
|
464
|
+
c.key.startsWith("__workspace__/"),
|
|
465
|
+
);
|
|
466
|
+
expect(before).toBeDefined();
|
|
467
|
+
|
|
468
|
+
touchFile(hookFile);
|
|
469
|
+
await getUserHooksFor("post-model-call");
|
|
470
|
+
|
|
471
|
+
const after = _inspectHookCacheForTests().find((c) =>
|
|
472
|
+
c.key.startsWith("__workspace__/"),
|
|
473
|
+
);
|
|
474
|
+
expect(after?.sourceMtime).not.toBe(before?.sourceMtime);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("deleting a workspace hook file evicts it on next read", async () => {
|
|
478
|
+
const hookFile = join(WORKSPACE_HOOKS_DIR, "stop.ts");
|
|
479
|
+
writeWorkspaceHook("stop", `export default () => ({ v: 1 });`);
|
|
480
|
+
|
|
481
|
+
await populateCacheAtBoot();
|
|
482
|
+
expect(await getUserHooksFor("stop")).toHaveLength(1);
|
|
483
|
+
|
|
484
|
+
rmSync(hookFile, { force: true });
|
|
485
|
+
|
|
486
|
+
const hooks = await getUserHooksFor("stop");
|
|
487
|
+
expect(hooks).toHaveLength(0);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("a newly added workspace hook is picked up without restart", async () => {
|
|
491
|
+
await populateCacheAtBoot();
|
|
492
|
+
expect(await getUserHooksFor("post-compact")).toHaveLength(0);
|
|
493
|
+
|
|
494
|
+
writeWorkspaceHook("post-compact", `export default () => ({ v: 1 });`);
|
|
495
|
+
|
|
496
|
+
expect(await getUserHooksFor("post-compact")).toHaveLength(1);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("a workspace init hook runs once at boot", async () => {
|
|
500
|
+
// The init hook writes a sentinel file so we can observe it ran exactly
|
|
501
|
+
// once during populateCacheAtBoot.
|
|
502
|
+
const sentinel = join(ROOT, "ws-init-ran.txt");
|
|
503
|
+
rmSync(sentinel, { force: true });
|
|
504
|
+
writeWorkspaceHook(
|
|
505
|
+
"init",
|
|
506
|
+
`import { appendFileSync } from "node:fs";
|
|
507
|
+
export default () => { appendFileSync(${JSON.stringify(sentinel)}, "x"); };`,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
await populateCacheAtBoot();
|
|
511
|
+
|
|
512
|
+
const { readFileSync: rf, existsSync: ex } = await import("node:fs");
|
|
513
|
+
expect(ex(sentinel)).toBe(true);
|
|
514
|
+
expect(rf(sentinel, "utf8")).toBe("x");
|
|
515
|
+
});
|
|
516
|
+
});
|
|
@@ -11,7 +11,7 @@ let hooksDir: string;
|
|
|
11
11
|
function freshWorkspace(): void {
|
|
12
12
|
workspaceDir = join(
|
|
13
13
|
tmpdir(),
|
|
14
|
-
`vellum-migration-
|
|
14
|
+
`vellum-migration-048-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
15
15
|
);
|
|
16
16
|
hooksDir = join(workspaceDir, "hooks");
|
|
17
17
|
mkdirSync(workspaceDir, { recursive: true });
|
|
@@ -30,29 +30,22 @@ afterEach(() => {
|
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
-
describe("048-remove-workspace-hooks migration", () => {
|
|
33
|
+
describe("048-remove-workspace-hooks migration (retained no-op)", () => {
|
|
34
34
|
test("has correct migration id", () => {
|
|
35
35
|
expect(removeWorkspaceHooksMigration.id).toBe("048-remove-workspace-hooks");
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test("
|
|
38
|
+
test("preserves a populated hooks directory now that it is a supported surface", () => {
|
|
39
39
|
mkdirSync(hooksDir, { recursive: true });
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(hooksDir, "user-prompt-submit.ts"),
|
|
42
|
+
"export default () => {};",
|
|
43
|
+
);
|
|
44
44
|
|
|
45
45
|
removeWorkspaceHooksMigration.run(workspaceDir);
|
|
46
46
|
|
|
47
|
-
expect(existsSync(hooksDir)).toBe(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
test("removes an empty hooks directory", () => {
|
|
51
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
52
|
-
|
|
53
|
-
removeWorkspaceHooksMigration.run(workspaceDir);
|
|
54
|
-
|
|
55
|
-
expect(existsSync(hooksDir)).toBe(false);
|
|
47
|
+
expect(existsSync(hooksDir)).toBe(true);
|
|
48
|
+
expect(existsSync(join(hooksDir, "user-prompt-submit.ts"))).toBe(true);
|
|
56
49
|
});
|
|
57
50
|
|
|
58
51
|
test("no-op when the hooks directory does not exist", () => {
|
|
@@ -65,35 +58,21 @@ describe("048-remove-workspace-hooks migration", () => {
|
|
|
65
58
|
expect(existsSync(workspaceDir)).toBe(true);
|
|
66
59
|
});
|
|
67
60
|
|
|
68
|
-
test("idempotent — safe to re-run
|
|
61
|
+
test("idempotent — safe to re-run", () => {
|
|
69
62
|
mkdirSync(hooksDir, { recursive: true });
|
|
70
|
-
writeFileSync(join(hooksDir, "
|
|
63
|
+
writeFileSync(join(hooksDir, "stop.ts"), "export default () => {};");
|
|
71
64
|
|
|
72
65
|
removeWorkspaceHooksMigration.run(workspaceDir);
|
|
73
|
-
// Second invocation is a no-op and must not throw.
|
|
74
66
|
removeWorkspaceHooksMigration.run(workspaceDir);
|
|
75
67
|
|
|
76
|
-
expect(existsSync(hooksDir)).toBe(
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("does not touch unrelated workspace entries", () => {
|
|
80
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
81
|
-
writeFileSync(join(hooksDir, "stale.json"), "{}");
|
|
82
|
-
const skillsDir = join(workspaceDir, "skills");
|
|
83
|
-
mkdirSync(skillsDir, { recursive: true });
|
|
84
|
-
writeFileSync(join(skillsDir, "keep.md"), "keep me");
|
|
85
|
-
|
|
86
|
-
removeWorkspaceHooksMigration.run(workspaceDir);
|
|
87
|
-
|
|
88
|
-
expect(existsSync(hooksDir)).toBe(false);
|
|
89
|
-
expect(existsSync(skillsDir)).toBe(true);
|
|
90
|
-
expect(existsSync(join(skillsDir, "keep.md"))).toBe(true);
|
|
68
|
+
expect(existsSync(join(hooksDir, "stop.ts"))).toBe(true);
|
|
91
69
|
});
|
|
92
70
|
|
|
93
71
|
describe("down()", () => {
|
|
94
72
|
test("is a no-op", () => {
|
|
73
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
95
74
|
removeWorkspaceHooksMigration.down(workspaceDir);
|
|
96
|
-
expect(existsSync(hooksDir)).toBe(
|
|
75
|
+
expect(existsSync(hooksDir)).toBe(true);
|
|
97
76
|
});
|
|
98
77
|
});
|
|
99
78
|
});
|
|
@@ -583,12 +583,20 @@ function normalizeCardShowData(
|
|
|
583
583
|
(typeof input[k] === "string" &&
|
|
584
584
|
(input[k] as string).trim().length > 0),
|
|
585
585
|
);
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
586
|
+
try {
|
|
587
|
+
Sentry.withScope((scope) => {
|
|
588
|
+
scope.setLevel("info");
|
|
589
|
+
scope.setTag("card_normalization", "alias_recovery");
|
|
590
|
+
scope.setTag("target_field", "body");
|
|
591
|
+
scope.setContext("card_normalization", {
|
|
592
|
+
used_aliases: usedAliases,
|
|
593
|
+
candidate_count: candidates.length,
|
|
594
|
+
});
|
|
595
|
+
Sentry.captureMessage("card_normalization:alias_recovery:body");
|
|
596
|
+
});
|
|
597
|
+
} catch {
|
|
598
|
+
// Never let telemetry break card rendering.
|
|
599
|
+
}
|
|
592
600
|
}
|
|
593
601
|
}
|
|
594
602
|
for (const key of bodyAliasKeys) {
|
|
@@ -605,11 +613,16 @@ function normalizeCardShowData(
|
|
|
605
613
|
]);
|
|
606
614
|
if (aliased !== undefined) {
|
|
607
615
|
normalized.title = aliased;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
616
|
+
try {
|
|
617
|
+
Sentry.withScope((scope) => {
|
|
618
|
+
scope.setLevel("info");
|
|
619
|
+
scope.setTag("card_normalization", "alias_recovery");
|
|
620
|
+
scope.setTag("target_field", "title");
|
|
621
|
+
Sentry.captureMessage("card_normalization:alias_recovery:title");
|
|
622
|
+
});
|
|
623
|
+
} catch {
|
|
624
|
+
// Never let telemetry break card rendering.
|
|
625
|
+
}
|
|
613
626
|
}
|
|
614
627
|
}
|
|
615
628
|
for (const key of titleAliasKeys) {
|
|
@@ -634,11 +647,16 @@ function normalizeCardShowData(
|
|
|
634
647
|
]);
|
|
635
648
|
if (aliased !== undefined) {
|
|
636
649
|
normalized.subtitle = aliased;
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
650
|
+
try {
|
|
651
|
+
Sentry.withScope((scope) => {
|
|
652
|
+
scope.setLevel("info");
|
|
653
|
+
scope.setTag("card_normalization", "alias_recovery");
|
|
654
|
+
scope.setTag("target_field", "subtitle");
|
|
655
|
+
Sentry.captureMessage("card_normalization:alias_recovery:subtitle");
|
|
656
|
+
});
|
|
657
|
+
} catch {
|
|
658
|
+
// Never let telemetry break card rendering.
|
|
659
|
+
}
|
|
642
660
|
}
|
|
643
661
|
}
|
|
644
662
|
for (const key of subtitleAliasKeys) {
|
|
@@ -688,12 +706,21 @@ function normalizeCardShowData(
|
|
|
688
706
|
{ droppedKeys },
|
|
689
707
|
"ui_show card data carried keys the card contract does not model; their content will not render",
|
|
690
708
|
);
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
709
|
+
try {
|
|
710
|
+
Sentry.withScope((scope) => {
|
|
711
|
+
scope.setLevel("warning");
|
|
712
|
+
scope.setTag("card_normalization", "dropped_keys");
|
|
713
|
+
scope.setContext("card_normalization", {
|
|
714
|
+
dropped_count: droppedKeys.length,
|
|
715
|
+
});
|
|
716
|
+
// Key names are model-controlled, so they ride in `extra`, which
|
|
717
|
+
// beforeSend redacts (it does not scrub `contexts`) — see instrument.ts.
|
|
718
|
+
scope.setExtra("dropped_keys", droppedKeys);
|
|
719
|
+
Sentry.captureMessage("card_normalization:dropped_keys");
|
|
720
|
+
});
|
|
721
|
+
} catch {
|
|
722
|
+
// Never let telemetry break card rendering.
|
|
723
|
+
}
|
|
697
724
|
}
|
|
698
725
|
const parsed = CardSurfaceDataSchema.safeParse(normalized);
|
|
699
726
|
if (parsed.success) {
|
|
@@ -2912,18 +2939,27 @@ export async function surfaceProxyResolver(
|
|
|
2912
2939
|
if (result.success) {
|
|
2913
2940
|
valid.push(result.data);
|
|
2914
2941
|
} else {
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2942
|
+
try {
|
|
2943
|
+
Sentry.withScope((scope) => {
|
|
2944
|
+
scope.setLevel("warning");
|
|
2945
|
+
scope.setTag("card_normalization", "action_parse_failure");
|
|
2946
|
+
scope.setContext("card_normalization", {
|
|
2947
|
+
issue_paths: result.error.issues.map((i) => i.path.join(".")),
|
|
2948
|
+
});
|
|
2949
|
+
// raw object keys are model-controlled, so they ride in `extra`,
|
|
2950
|
+
// which beforeSend redacts (it does not scrub `contexts`) — see
|
|
2951
|
+
// instrument.ts.
|
|
2952
|
+
scope.setExtra(
|
|
2953
|
+
"raw_keys",
|
|
2922
2954
|
typeof raw === "object" && raw !== null
|
|
2923
2955
|
? Object.keys(raw)
|
|
2924
2956
|
: [typeof raw],
|
|
2925
|
-
|
|
2926
|
-
|
|
2957
|
+
);
|
|
2958
|
+
Sentry.captureMessage("card_normalization:action_parse_failure");
|
|
2959
|
+
});
|
|
2960
|
+
} catch {
|
|
2961
|
+
// Never let telemetry break card rendering.
|
|
2962
|
+
}
|
|
2927
2963
|
}
|
|
2928
2964
|
}
|
|
2929
2965
|
inputActions = valid.length > 0 ? valid : undefined;
|
|
@@ -2961,11 +2997,24 @@ export async function surfaceProxyResolver(
|
|
|
2961
2997
|
!hasTemplate &&
|
|
2962
2998
|
!hasActions
|
|
2963
2999
|
) {
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
3000
|
+
try {
|
|
3001
|
+
Sentry.withScope((scope) => {
|
|
3002
|
+
scope.setLevel("warning");
|
|
3003
|
+
scope.setTag("card_normalization", "empty_card_rejected");
|
|
3004
|
+
scope.setContext("card_normalization", {
|
|
3005
|
+
surface_type: surfaceType,
|
|
3006
|
+
has_title: hasTitle,
|
|
3007
|
+
has_body: hasBody,
|
|
3008
|
+
has_subtitle: hasSubtitle,
|
|
3009
|
+
has_metadata: hasMetadata,
|
|
3010
|
+
has_template: hasTemplate,
|
|
3011
|
+
has_actions: hasActions,
|
|
3012
|
+
});
|
|
3013
|
+
Sentry.captureMessage("card_normalization:empty_card_rejected");
|
|
3014
|
+
});
|
|
3015
|
+
} catch {
|
|
3016
|
+
// Never let telemetry break card rendering.
|
|
3017
|
+
}
|
|
2969
3018
|
return {
|
|
2970
3019
|
content:
|
|
2971
3020
|
"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.",
|