@vellumai/assistant 0.9.0-staging.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
- package/openapi.yaml +99 -0
- package/package.json +1 -1
- package/src/__tests__/app-compiler.test.ts +7 -1
- package/src/__tests__/app-executors.test.ts +43 -0
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +97 -0
- package/src/__tests__/dynamic-page-surface.test.ts +94 -0
- package/src/__tests__/mock-gateway-ipc.ts +23 -0
- package/src/__tests__/resolve-app-id.test.ts +56 -0
- package/src/bundler/package-resolver.ts +0 -1
- package/src/config/bundled-skills/app-builder/SKILL.md +1 -1
- package/src/config/bundled-skills/app-builder/tools/app-create.ts +6 -1
- package/src/config/bundled-skills/app-builder/tools/app-generate-icon.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-refresh.ts +7 -1
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +10 -1
- package/src/daemon/conversation-surfaces.ts +92 -3
- package/src/notifications/home-feed-side-effect.ts +27 -10
- package/src/runtime/channel-invite-transports/telegram.ts +6 -5
- package/src/runtime/channel-invite-transports/voice.ts +2 -2
- package/src/runtime/channel-invite-types.ts +4 -2
- package/src/runtime/channel-retry-sweep.ts +19 -41
- package/src/runtime/finalize-event-delivery.ts +72 -0
- package/src/runtime/routes/channel-delivery-routes.ts +11 -7
- package/src/runtime/routes/channel-route-definitions.ts +3 -0
- package/src/runtime/routes/inbound-message-handler.ts +13 -12
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +12 -36
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +7 -5
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -21
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +5 -5
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +6 -15
- package/src/runtime/routes/inbound-stages/secret-ingress-check.ts +1 -1
- package/src/schedule/schedule-store.ts +30 -0
- package/src/tools/apps/executors.ts +23 -0
- package/src/tools/apps/resolve-app-id.ts +42 -0
- package/src/tools/ui-surface/definitions.ts +43 -0
|
@@ -85,7 +85,7 @@ mock.module("../../gateway-client.js", () => ({
|
|
|
85
85
|
},
|
|
86
86
|
}));
|
|
87
87
|
|
|
88
|
-
mock.module("
|
|
88
|
+
mock.module("../../channel-reply-delivery.js", () => ({
|
|
89
89
|
deliverReplyViaCallback: async (...args: unknown[]) => {
|
|
90
90
|
const options = args[4] as
|
|
91
91
|
| { messageId?: string; startFromSegment?: number; messageTs?: string }
|
|
@@ -296,7 +296,7 @@ describe("processChannelMessageInBackground — slack thread mapping", () => {
|
|
|
296
296
|
},
|
|
297
297
|
]);
|
|
298
298
|
expect(replyDeliveryCalls).toEqual([
|
|
299
|
-
{ messageId: "assistant-msg-delivery-failure" },
|
|
299
|
+
{ messageId: "assistant-msg-delivery-failure", startFromSegment: 0 },
|
|
300
300
|
]);
|
|
301
301
|
expect(deliveryFailureEvents).toEqual(["evt-delivery-failure"]);
|
|
302
302
|
expect(deliveredEvents).toEqual([]);
|
|
@@ -336,7 +336,7 @@ describe("processChannelMessageInBackground — slack thread mapping", () => {
|
|
|
336
336
|
},
|
|
337
337
|
]);
|
|
338
338
|
expect(replyDeliveryCalls).toEqual([
|
|
339
|
-
{ messageId: "assistant-msg-fast-path" },
|
|
339
|
+
{ messageId: "assistant-msg-fast-path", startFromSegment: 0 },
|
|
340
340
|
]);
|
|
341
341
|
expect(deliveredEvents).toEqual(["evt-fast-path"]);
|
|
342
342
|
|
|
@@ -433,6 +433,7 @@ describe("processChannelMessageInBackground — slack thread mapping", () => {
|
|
|
433
433
|
]);
|
|
434
434
|
expect(deliveredSegmentCounts).toEqual([
|
|
435
435
|
{ eventId: "evt-incremental-text", count: 1 },
|
|
436
|
+
{ eventId: "evt-incremental-text", count: 1 },
|
|
436
437
|
]);
|
|
437
438
|
expect(storedReplyMessageIds).toEqual([
|
|
438
439
|
{
|
|
@@ -574,7 +575,7 @@ describe("processChannelMessageInBackground — slack thread mapping", () => {
|
|
|
574
575
|
.filter(Boolean),
|
|
575
576
|
).toEqual([]);
|
|
576
577
|
expect(replyDeliveryCalls).toEqual([
|
|
577
|
-
{ messageId: "assistant-msg-channel-final" },
|
|
578
|
+
{ messageId: "assistant-msg-channel-final", startFromSegment: 0 },
|
|
578
579
|
]);
|
|
579
580
|
expect(deliveredEvents).toEqual(["evt-channel-final-delivery"]);
|
|
580
581
|
|
|
@@ -658,11 +659,12 @@ describe("processChannelMessageInBackground — slack thread mapping", () => {
|
|
|
658
659
|
.filter(Boolean),
|
|
659
660
|
).toEqual(["First live response.", "Second live response."]);
|
|
660
661
|
expect(replyDeliveryCalls).toEqual([
|
|
661
|
-
{ messageId: "assistant-msg-live-failure-final" },
|
|
662
|
+
{ messageId: "assistant-msg-live-failure-final", startFromSegment: 0 },
|
|
662
663
|
]);
|
|
663
664
|
expect(deliveryFailureEvents).toEqual([]);
|
|
664
665
|
expect(deliveredEvents).toEqual(["evt-live-failure-recovery"]);
|
|
665
666
|
expect(deliveredSegmentCounts).toEqual([
|
|
667
|
+
{ eventId: "evt-live-failure-recovery", count: 0 },
|
|
666
668
|
{ eventId: "evt-live-failure-recovery", count: 1 },
|
|
667
669
|
]);
|
|
668
670
|
|
|
@@ -14,16 +14,13 @@ import type { TrustContext } from "../../../daemon/trust-context.js";
|
|
|
14
14
|
import {
|
|
15
15
|
addSlackDmLiveDeliveredTextResponseIndex,
|
|
16
16
|
getSlackDmLiveDeliveredTextResponseIndexes,
|
|
17
|
-
updateDeliveredSegmentCount,
|
|
18
17
|
} from "../../../memory/delivery-channels.js";
|
|
19
18
|
import {
|
|
20
19
|
linkMessage,
|
|
21
20
|
storeReplyMessageId,
|
|
22
21
|
} from "../../../memory/delivery-crud.js";
|
|
23
22
|
import {
|
|
24
|
-
markDeliveryDelivered,
|
|
25
23
|
markProcessed,
|
|
26
|
-
recordDeliveryFailure,
|
|
27
24
|
recordProcessingFailure,
|
|
28
25
|
} from "../../../memory/delivery-status.js";
|
|
29
26
|
import {
|
|
@@ -53,7 +50,7 @@ import {
|
|
|
53
50
|
isSlackDeliveryCallbackUrl,
|
|
54
51
|
} from "../../slack-dm-text-delivery.js";
|
|
55
52
|
import { resolveRoutingState } from "../../trust-context-resolver.js";
|
|
56
|
-
import {
|
|
53
|
+
import { finalizeEventDelivery } from "../channel-delivery-routes.js";
|
|
57
54
|
import { deliverGeneratedApprovalPrompt } from "../guardian-approval-prompt.js";
|
|
58
55
|
|
|
59
56
|
const log = getLogger("runtime-http");
|
|
@@ -326,32 +323,21 @@ export function processChannelMessageInBackground(
|
|
|
326
323
|
|
|
327
324
|
if (replyCallbackUrl) {
|
|
328
325
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
332
|
-
const liveDeliveryResumeOptions =
|
|
333
|
-
slackDmTextDelivery?.getFinalDeliveryResumeOptions(replyMessageId);
|
|
334
|
-
|
|
335
|
-
await deliverReplyViaCallback(
|
|
326
|
+
await finalizeEventDelivery({
|
|
327
|
+
eventId,
|
|
336
328
|
conversationId,
|
|
337
329
|
externalChatId,
|
|
338
330
|
replyCallbackUrl,
|
|
339
331
|
assistantId,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
onSegmentDelivered: (count) =>
|
|
345
|
-
updateDeliveredSegmentCount(eventId, count),
|
|
346
|
-
},
|
|
347
|
-
);
|
|
348
|
-
markDeliveryDelivered(eventId);
|
|
332
|
+
replyMessageId,
|
|
333
|
+
userMessageId,
|
|
334
|
+
slackDmTextDelivery,
|
|
335
|
+
});
|
|
349
336
|
} catch (err) {
|
|
350
337
|
log.error(
|
|
351
338
|
{ err, conversationId },
|
|
352
339
|
"Background channel reply delivery failed",
|
|
353
340
|
);
|
|
354
|
-
recordDeliveryFailure(eventId, err);
|
|
355
341
|
}
|
|
356
342
|
}
|
|
357
343
|
} finally {
|
|
@@ -34,7 +34,7 @@ export interface EscalationInterceptParams {
|
|
|
34
34
|
eventId: string;
|
|
35
35
|
content: string | undefined;
|
|
36
36
|
attachmentIds: string[] | undefined;
|
|
37
|
-
sourceMetadata:
|
|
37
|
+
sourceMetadata: import("@vellumai/gateway-client").SourceMetadata | undefined;
|
|
38
38
|
actorDisplayName: string | undefined;
|
|
39
39
|
actorExternalId: string | undefined;
|
|
40
40
|
actorUsername: string | undefined;
|
|
@@ -84,11 +84,11 @@ export function handleEscalationIntercept(
|
|
|
84
84
|
{ sourceChannel, channelId: resolvedMember.channel.id },
|
|
85
85
|
"Ingress ACL: escalate policy but no guardian binding, denying",
|
|
86
86
|
);
|
|
87
|
-
return
|
|
87
|
+
return {
|
|
88
88
|
accepted: true,
|
|
89
89
|
denied: true,
|
|
90
90
|
reason: "escalate_no_guardian",
|
|
91
|
-
}
|
|
91
|
+
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Persist the raw payload so the decide handler can recover the original
|
|
@@ -159,9 +159,9 @@ export function handleEscalationIntercept(
|
|
|
159
159
|
"Guardian escalation created — notification pipeline handles channel delivery",
|
|
160
160
|
);
|
|
161
161
|
|
|
162
|
-
return
|
|
162
|
+
return {
|
|
163
163
|
accepted: true,
|
|
164
164
|
escalated: true,
|
|
165
165
|
reason: "policy_escalate",
|
|
166
|
-
}
|
|
166
|
+
};
|
|
167
167
|
}
|
|
@@ -32,7 +32,7 @@ export interface GuardianActivationInterceptParams {
|
|
|
32
32
|
canonicalSenderId: string | null;
|
|
33
33
|
actorDisplayName: string | undefined;
|
|
34
34
|
actorUsername: string | undefined;
|
|
35
|
-
sourceMetadata:
|
|
35
|
+
sourceMetadata: import("@vellumai/gateway-client").SourceMetadata | undefined;
|
|
36
36
|
replyCallbackUrl: string | undefined;
|
|
37
37
|
assistantId: string;
|
|
38
38
|
externalMessageId: string;
|
|
@@ -75,23 +75,14 @@ export async function handleGuardianActivationIntercept(
|
|
|
75
75
|
} = params;
|
|
76
76
|
|
|
77
77
|
// ── Extract commandIntent ──
|
|
78
|
-
const
|
|
79
|
-
const commandIntent =
|
|
80
|
-
rawCommandIntent &&
|
|
81
|
-
typeof rawCommandIntent === "object" &&
|
|
82
|
-
!Array.isArray(rawCommandIntent)
|
|
83
|
-
? (rawCommandIntent as Record<string, unknown>)
|
|
84
|
-
: undefined;
|
|
78
|
+
const commandIntent = sourceMetadata?.commandIntent;
|
|
85
79
|
|
|
86
80
|
// Only proceed for /start commands
|
|
87
81
|
if (!commandIntent || commandIntent.type !== "start") return null;
|
|
88
82
|
|
|
89
83
|
// If /start has a payload (e.g. gv_token, iv_token), let the existing
|
|
90
84
|
// bootstrap/invite handlers deal with it.
|
|
91
|
-
if (
|
|
92
|
-
typeof commandIntent.payload === "string" &&
|
|
93
|
-
commandIntent.payload.length > 0
|
|
94
|
-
) {
|
|
85
|
+
if (commandIntent.payload && commandIntent.payload.length > 0) {
|
|
95
86
|
return null;
|
|
96
87
|
}
|
|
97
88
|
|
|
@@ -110,7 +101,7 @@ export async function handleGuardianActivationIntercept(
|
|
|
110
101
|
// Only checked here; marked as processed after successful session creation
|
|
111
102
|
// so transient failures remain retryable.
|
|
112
103
|
if (isAlreadyProcessed(externalMessageId)) {
|
|
113
|
-
return
|
|
104
|
+
return { accepted: true, guardianActivation: true };
|
|
114
105
|
}
|
|
115
106
|
|
|
116
107
|
// ── Idempotency: check for an existing active session from this sender ──
|
|
@@ -138,7 +129,7 @@ export async function handleGuardianActivationIntercept(
|
|
|
138
129
|
});
|
|
139
130
|
}
|
|
140
131
|
markProcessed(externalMessageId);
|
|
141
|
-
return
|
|
132
|
+
return { accepted: true, guardianActivationPending: true };
|
|
142
133
|
}
|
|
143
134
|
}
|
|
144
135
|
|
|
@@ -193,5 +184,5 @@ export async function handleGuardianActivationIntercept(
|
|
|
193
184
|
dedupeKey: `guardian-activation:${sessionResult.sessionId}`,
|
|
194
185
|
});
|
|
195
186
|
|
|
196
|
-
return
|
|
187
|
+
return { accepted: true, guardianActivation: true };
|
|
197
188
|
}
|
|
@@ -27,7 +27,7 @@ export interface SecretIngressCheckParams {
|
|
|
27
27
|
content: string | undefined;
|
|
28
28
|
trimmedContent: string;
|
|
29
29
|
attachmentIds: string[] | undefined;
|
|
30
|
-
sourceMetadata:
|
|
30
|
+
sourceMetadata: import("@vellumai/gateway-client").SourceMetadata | undefined;
|
|
31
31
|
actorDisplayName: string | undefined;
|
|
32
32
|
actorExternalId: string | undefined;
|
|
33
33
|
actorUsername: string | undefined;
|
|
@@ -986,6 +986,36 @@ export function describeCronExpression(expr: string | null): string {
|
|
|
986
986
|
}
|
|
987
987
|
}
|
|
988
988
|
|
|
989
|
+
// Stepped or fixed minutes constrained to a contiguous range of hours,
|
|
990
|
+
// every day/month (e.g. "*/30 7-23 * * *" → "Every 30 minutes, 7 AM–11 PM").
|
|
991
|
+
const hoursAreContiguousRange =
|
|
992
|
+
!allHours &&
|
|
993
|
+
activeHours.length > 1 &&
|
|
994
|
+
activeHours.every((h, i) => i === 0 || h === activeHours[i - 1] + 1);
|
|
995
|
+
|
|
996
|
+
if (hoursAreContiguousRange && anyDayAndMonth) {
|
|
997
|
+
const hourLabel = (h: number) => {
|
|
998
|
+
const period = h >= 12 ? "PM" : "AM";
|
|
999
|
+
return `${h % 12 || 12} ${period}`;
|
|
1000
|
+
};
|
|
1001
|
+
const rangeStr = `${hourLabel(activeHours[0])}–${hourLabel(
|
|
1002
|
+
activeHours[activeHours.length - 1],
|
|
1003
|
+
)}`;
|
|
1004
|
+
|
|
1005
|
+
if (steppedMinutes && activeMinutes[0] === 0) {
|
|
1006
|
+
const step = activeMinutes[1] - activeMinutes[0];
|
|
1007
|
+
const isRegularStep = activeMinutes.every((v, i) => v === i * step);
|
|
1008
|
+
if (isRegularStep && 60 % step === 0) {
|
|
1009
|
+
return `Every ${step} minutes, ${rangeStr}`;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (fixedMinute) {
|
|
1013
|
+
return activeMinutes[0] === 0
|
|
1014
|
+
? `Hourly, ${rangeStr}`
|
|
1015
|
+
: `Hourly at minute ${activeMinutes[0]}, ${rangeStr}`;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
989
1019
|
// Fallback: return the raw expression
|
|
990
1020
|
return expr;
|
|
991
1021
|
} catch {
|
|
@@ -12,6 +12,7 @@ import { compileApp } from "../../bundler/app-compiler.js";
|
|
|
12
12
|
import { generateAppIcon } from "../../media/app-icon-generator.js";
|
|
13
13
|
import type { AppDefinition } from "../../memory/app-store.js";
|
|
14
14
|
import { getAppDirPath } from "../../memory/app-store.js";
|
|
15
|
+
import { getLogger } from "../../util/logger.js";
|
|
15
16
|
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Shared result type
|
|
@@ -55,6 +56,11 @@ export interface AppStoreWriter {
|
|
|
55
56
|
): AppDefinition;
|
|
56
57
|
deleteApp(id: string): void;
|
|
57
58
|
writeAppFile(appId: string, path: string, content: string): void;
|
|
59
|
+
/**
|
|
60
|
+
* Associate a freshly created app with the conversation that created it.
|
|
61
|
+
* Optional so test doubles need not implement it; the real store does.
|
|
62
|
+
*/
|
|
63
|
+
addAppConversationId?(appId: string, conversationId: string): boolean;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
export type AppStore = AppStoreReader & AppStoreWriter;
|
|
@@ -158,6 +164,7 @@ export async function executeAppCreate(
|
|
|
158
164
|
input: AppCreateInput,
|
|
159
165
|
store: AppStore,
|
|
160
166
|
proxyToolResolver?: ProxyResolver,
|
|
167
|
+
conversationId?: string,
|
|
161
168
|
): Promise<ExecutorResult> {
|
|
162
169
|
// The model sometimes omits a name; resolve a sensible one rather than
|
|
163
170
|
// erroring out so the build still succeeds. Users can rename via app_update.
|
|
@@ -213,6 +220,22 @@ export async function executeAppCreate(
|
|
|
213
220
|
formatVersion: 2,
|
|
214
221
|
});
|
|
215
222
|
|
|
223
|
+
// Associate the app with its conversation at creation so subsequent
|
|
224
|
+
// `app_*` calls in the same turn can resolve it even when the model omits
|
|
225
|
+
// `app_id` (see resolveAppId). Without this the link is only formed at
|
|
226
|
+
// `app_open`, leaving the create→update→refresh gap unresolvable.
|
|
227
|
+
// Best-effort: a failed association must never fail the create.
|
|
228
|
+
if (conversationId && store.addAppConversationId) {
|
|
229
|
+
try {
|
|
230
|
+
store.addAppConversationId(app.id, conversationId);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
getLogger("app-executors").debug(
|
|
233
|
+
{ err, appId: app.id, conversationId },
|
|
234
|
+
"Failed to associate app with conversation at create",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
216
239
|
// Scaffold multifile app with src/ files and compile to dist/
|
|
217
240
|
const htmlSafeName = name
|
|
218
241
|
.replace(/&/g, "&")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { listAppsByConversation } from "../../memory/app-store.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the `app_id` an app-builder tool should operate on.
|
|
5
|
+
*
|
|
6
|
+
* Weaker models routinely omit `app_id` when calling `app_*` tools through
|
|
7
|
+
* `skill_execute`, even though the active app's id is in their context from the
|
|
8
|
+
* preceding `app_create`/`app_update` result. Left unhandled, the executor
|
|
9
|
+
* fails with a cryptic "Invalid ID: undefined", the model retries the same
|
|
10
|
+
* empty call, and the turn burns many steps before recovering.
|
|
11
|
+
*
|
|
12
|
+
* An explicit, non-empty `app_id` always wins. When it is missing, fall back to
|
|
13
|
+
* the conversation's most-recently-updated app — the one being actively built.
|
|
14
|
+
* Returns `null` when no app_id is supplied and the conversation has no app, so
|
|
15
|
+
* callers can surface an actionable error instead of a raw store throw.
|
|
16
|
+
*
|
|
17
|
+
* Not used for destructive operations (`app_delete`): deleting an inferred app
|
|
18
|
+
* is unsafe, so deletion requires an explicit id.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveAppId(
|
|
21
|
+
input: Record<string, unknown>,
|
|
22
|
+
conversationId: string,
|
|
23
|
+
): string | null {
|
|
24
|
+
if (typeof input.app_id === "string" && input.app_id.trim().length > 0) {
|
|
25
|
+
return input.app_id;
|
|
26
|
+
}
|
|
27
|
+
// `listAppsByConversation` preserves `listApps`' updatedAt-descending order,
|
|
28
|
+
// so the first entry is the app the model is actively working on.
|
|
29
|
+
const apps = listAppsByConversation(conversationId);
|
|
30
|
+
return apps.length > 0 ? apps[0].id : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Error payload returned when no app_id is supplied and none can be inferred. */
|
|
34
|
+
export function missingAppIdError(): { content: string; isError: boolean } {
|
|
35
|
+
return {
|
|
36
|
+
content: JSON.stringify({
|
|
37
|
+
error:
|
|
38
|
+
"app_id is required and no active app exists in this conversation. Call app_create first, or pass app_id explicitly.",
|
|
39
|
+
}),
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -43,6 +43,14 @@ function proxyExecute(toolName: string) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
if (toolName === "ui_show" && isEmptyCard(input)) {
|
|
47
|
+
return {
|
|
48
|
+
content:
|
|
49
|
+
"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.",
|
|
50
|
+
isError: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
if (toolName === "ui_show" && isDynamicPageAppSubstitute(input)) {
|
|
47
55
|
return {
|
|
48
56
|
content:
|
|
@@ -99,6 +107,41 @@ function isEmptyDynamicPage(input: Record<string, unknown>): boolean {
|
|
|
99
107
|
return typeof html !== "string" || html.trim().length === 0;
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A `card` ui_show carrying no renderable content — only a title (or nothing)
|
|
112
|
+
* — renders as a blank bordered box. A declared `template` (task_progress,
|
|
113
|
+
* weather_forecast, …) renders its own shell, and `body`/`subtitle`/`metadata`/
|
|
114
|
+
* `actions` are real content; any of those passes. The model places these
|
|
115
|
+
* either nested in `data` or at the top level, so both are checked. Title is
|
|
116
|
+
* intentionally not content: a title-only card is the blank box.
|
|
117
|
+
*/
|
|
118
|
+
function isEmptyCard(input: Record<string, unknown>): boolean {
|
|
119
|
+
if (input.surface_type !== "card") {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const data = asRecord(input.data) ?? {};
|
|
123
|
+
|
|
124
|
+
const template =
|
|
125
|
+
nonEmptyString(input.template) ?? nonEmptyString(data.template);
|
|
126
|
+
if (template) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const hasBody = !!(nonEmptyString(input.body) ?? nonEmptyString(data.body));
|
|
131
|
+
const hasSubtitle = !!nonEmptyString(data.subtitle);
|
|
132
|
+
const hasMetadata = Array.isArray(data.metadata) && data.metadata.length > 0;
|
|
133
|
+
const actions = input.actions ?? data.actions;
|
|
134
|
+
const hasActions = Array.isArray(actions) && actions.length > 0;
|
|
135
|
+
|
|
136
|
+
return !(hasBody || hasSubtitle || hasMetadata || hasActions);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
140
|
+
return typeof value === "string" && value.trim().length > 0
|
|
141
|
+
? value
|
|
142
|
+
: undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
102
145
|
function isDynamicPageAppSubstitute(input: Record<string, unknown>): boolean {
|
|
103
146
|
if (input.surface_type !== "dynamic_page") {
|
|
104
147
|
return false;
|