@vellumai/assistant 0.11.1 → 0.11.2-staging.2
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/Dockerfile +1 -3
- package/README.md +1 -1
- package/eslint.config.mjs +28 -8
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/stripe-currency.test.ts +37 -0
- package/node_modules/@vellumai/service-contracts/src/stripe-currency.ts +55 -0
- package/node_modules/@vellumai/slack-text/src/index.test.ts +48 -0
- package/node_modules/@vellumai/slack-text/src/index.ts +25 -6
- package/openapi.yaml +292 -5
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/byok-default-profile-ensure.test.ts +43 -0
- package/src/__tests__/cli-logger-boundary-guard.test.ts +87 -0
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/config-loader-backfill.test.ts +35 -3
- package/src/__tests__/config-schema.test.ts +1 -0
- package/src/__tests__/conversation-agent-loop-fatal-cleanup.test.ts +30 -0
- package/src/__tests__/conversation-agent-loop.test.ts +180 -7
- package/src/__tests__/conversation-error.test.ts +211 -4
- package/src/__tests__/conversation-options-turn-scoped-transport.test.ts +64 -0
- package/src/__tests__/conversation-queue.test.ts +126 -0
- package/src/__tests__/conversation-retry-route.test.ts +46 -7
- package/src/__tests__/conversation-slash.test.ts +4 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/document-create-dedupe.test.ts +2 -1
- package/src/__tests__/document-find-replace.test.ts +2 -1
- package/src/__tests__/document-tool-security.test.ts +2 -1
- package/src/__tests__/document-update-default-surface.test.ts +2 -1
- package/src/__tests__/document-workspace-file.test.ts +467 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +290 -49
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/http-user-message-parity.test.ts +66 -0
- package/src/__tests__/list-messages-attachments.test.ts +87 -0
- package/src/__tests__/list-messages-provider-error.test.ts +143 -0
- package/src/__tests__/list-messages-system-card.test.ts +100 -0
- package/src/__tests__/llm-resolver.test.ts +19 -9
- package/src/__tests__/managed-profile-guard.test.ts +23 -0
- package/src/__tests__/notification-platform-adapter.test.ts +130 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/notification-vellum-adapter.test.ts +45 -0
- package/src/__tests__/plugin-api-model-profiles.test.ts +10 -1
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/run-conversation-turn-persistence.test.ts +66 -3
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +209 -0
- package/src/__tests__/skills.test.ts +27 -0
- package/src/__tests__/slack-channels-routes.test.ts +0 -2
- package/src/__tests__/slack-share-routes.test.ts +0 -3
- package/src/__tests__/slack-users-routes.test.ts +0 -2
- package/src/__tests__/subagent-tools.test.ts +11 -0
- package/src/__tests__/tool-preview-lifecycle.test.ts +58 -0
- package/src/__tests__/tool-result-spool.test.ts +5 -4
- package/src/__tests__/turn-boundary-resolution.test.ts +53 -0
- package/src/__tests__/turn-events-store.test.ts +26 -0
- package/src/__tests__/ui-visual-surface.test.ts +695 -0
- package/src/__tests__/unified-turn-context-visible-app.test.ts +99 -0
- package/src/__tests__/visible-app-context.test.ts +189 -0
- package/src/__tests__/workspace-git-service.test.ts +173 -33
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +157 -0
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/__tests__/workspace-migration-139-clear-renamed-cost-profile-label.test.ts +137 -0
- package/src/agent/loop.ts +36 -7
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/events/notification-intent.ts +8 -0
- package/src/api/events/ui-surface-pending.ts +35 -0
- package/src/api/index.ts +14 -0
- package/src/api/responses/conversation-message.ts +25 -4
- package/src/api/surfaces.ts +90 -1
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/call-setup-router.test.ts +156 -26
- package/src/calls/__tests__/voice-session-bridge.test.ts +201 -12
- package/src/calls/call-setup-router.ts +80 -36
- package/src/calls/voice-session-bridge.ts +173 -26
- package/src/cli/commands/inference.help.ts +3 -3
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +42 -128
- package/src/cli/commands/platform/__tests__/credits.test.ts +9 -78
- package/src/cli/commands/platform/__tests__/helpers.ts +90 -0
- package/src/cli/commands/platform/__tests__/invoices.test.ts +238 -0
- package/src/cli/commands/platform/__tests__/plans.test.ts +9 -88
- package/src/cli/commands/platform/__tests__/status.test.ts +12 -87
- package/src/cli/commands/platform/__tests__/subscription.test.ts +9 -86
- package/src/cli/commands/platform/index.help.ts +92 -0
- package/src/cli/commands/platform/index.ts +7 -0
- package/src/cli/commands/platform/invoices.ts +132 -0
- package/src/cli/commands/usage.help.ts +1 -1
- package/src/cli/lib/list-installed-plugins.ts +2 -1
- package/src/config/__tests__/default-profile-catalog.test.ts +7 -10
- package/src/config/__tests__/deployment-context-defaults.test.ts +27 -5
- package/src/config/assistant-feature-flags.ts +7 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -2
- package/src/config/bundled-skills/subagent/SKILL.md +3 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +3 -3
- package/src/config/bundled-skills/visualize/SKILL.md +163 -0
- package/src/config/call-site-defaults.ts +3 -2
- package/src/config/default-profile-catalog.ts +75 -70
- package/src/config/default-profile-names.ts +13 -28
- package/src/config/env-registry.ts +1 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/llm-resolver.ts +21 -5
- package/src/config/loader.ts +18 -11
- package/src/config/schemas/llm.ts +1 -9
- package/src/config/schemas/memory-retrospective.ts +9 -0
- package/src/config/schemas/monitoring.ts +28 -2
- package/src/config/schemas/workspace-git.ts +15 -0
- package/src/config/seed-inference-profiles.ts +19 -5
- package/src/context/post-turn-tool-result-truncation.ts +2 -2
- package/src/conversations/__tests__/message-consolidation.test.ts +54 -0
- package/src/conversations/message-consolidation.ts +17 -15
- package/src/daemon/__tests__/turn-tail-assistant-reply-notify.test.ts +182 -0
- package/src/daemon/__tests__/turn-tail-deleted-conversation.test.ts +181 -0
- package/src/daemon/conversation-agent-loop-handlers.ts +90 -7
- package/src/daemon/conversation-agent-loop.ts +107 -22
- package/src/daemon/conversation-error.ts +169 -48
- package/src/daemon/conversation-messaging.ts +87 -4
- package/src/daemon/conversation-process.ts +57 -45
- package/src/daemon/conversation-runtime-assembly.ts +57 -0
- package/src/daemon/conversation-store.ts +36 -1
- package/src/daemon/conversation-surfaces.ts +68 -6
- package/src/daemon/conversation-turn-finalize.ts +76 -18
- package/src/daemon/conversation.ts +122 -27
- package/src/daemon/lifecycle.ts +9 -0
- package/src/daemon/message-types/conversations.ts +8 -0
- package/src/daemon/message-types/surfaces.ts +3 -0
- package/src/documents/document-store.ts +247 -10
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +95 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +310 -25
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +169 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +282 -41
- package/src/live-voice/protocol.ts +35 -0
- package/src/messaging/providers/slack/__tests__/adapter-mention-rendering.test.ts +84 -2
- package/src/messaging/providers/slack/adapter.ts +86 -19
- package/src/messaging/providers/slack/api.test.ts +85 -1
- package/src/messaging/providers/slack/api.ts +121 -288
- package/src/messaging/providers/slack/client.ts +20 -251
- package/src/messaging/providers/slack/send.test.ts +4 -9
- package/src/messaging/providers/slack/send.ts +1 -1
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/messaging/providers/slack/web-api-transport.test.ts +181 -0
- package/src/messaging/providers/slack/web-api-transport.ts +367 -0
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/monitoring/__tests__/db-integrity-sample.test.ts +18 -4
- package/src/monitoring/__tests__/file-descriptors.test.ts +144 -0
- package/src/monitoring/file-descriptors.ts +262 -0
- package/src/monitoring/process-memory.ts +4 -11
- package/src/monitoring/resource-sampler.ts +5 -0
- package/src/monitoring/worker.ts +12 -0
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +740 -0
- package/src/notifications/__tests__/broadcaster.test.ts +347 -7
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +349 -29
- package/src/notifications/__tests__/deterministic-checks.test.ts +21 -0
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/guardian-delivery-recorder.test.ts +71 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/adapters/macos.ts +3 -0
- package/src/notifications/adapters/platform.ts +90 -14
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/notifications/assistant-reply-producer.ts +219 -0
- package/src/notifications/broadcaster.ts +523 -317
- package/src/notifications/copy-composer.ts +27 -6
- package/src/notifications/decision-engine.ts +154 -77
- package/src/notifications/deterministic-checks.ts +6 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/emit-signal.ts +49 -16
- package/src/notifications/guardian-delivery-recorder.ts +8 -2
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +39 -4
- package/src/notifications/signal.ts +10 -0
- package/src/notifications/types.ts +17 -0
- package/src/persistence/bookmark-crud.ts +18 -9
- package/src/persistence/conversation-attention-store.ts +26 -0
- package/src/persistence/conversation-crud.ts +79 -32
- package/src/persistence/conversation-queries.ts +30 -15
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/persistence/conversation-types.ts +192 -1
- package/src/persistence/db-init.ts +14 -1
- package/src/persistence/db-maintenance.ts +5 -4
- package/src/persistence/embeddings/__tests__/plugin-index-qdrant-init.test.ts +219 -0
- package/src/persistence/embeddings/__tests__/plugin-index.test.ts +22 -0
- package/src/persistence/embeddings/__tests__/worker-script-version.test.ts +76 -0
- package/src/persistence/embeddings/embedding-local.ts +2 -1
- package/src/persistence/embeddings/embedding-runtime-manager.ts +29 -5
- package/src/persistence/embeddings/plugin-index.ts +84 -12
- package/src/persistence/migrations/360-add-document-workspace-path.test.ts +110 -0
- package/src/persistence/migrations/360-add-document-workspace-path.ts +39 -0
- package/src/persistence/planner-statistics.ts +14 -0
- package/src/persistence/schema/documents.ts +26 -11
- package/src/persistence/steps.ts +2 -0
- package/src/platform/client.test.ts +172 -2
- package/src/platform/client.ts +178 -55
- package/src/plugin-api/conversation-turn.ts +13 -0
- package/src/plugin-api/model-profiles.test.ts +6 -2
- package/src/plugins/defaults/memory/__tests__/bookmark-crud.test.ts +34 -0
- package/src/plugins/defaults/memory/__tests__/conversation-queries.test.ts +22 -0
- package/src/plugins/defaults/memory/__tests__/jobs-store-enqueue-gate.test.ts +11 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-accounting.test.ts +199 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-enqueue.test.ts +98 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +105 -1
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-sweep.test.ts +24 -2
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-wake-chain.test.ts +524 -0
- package/src/plugins/defaults/memory/__tests__/memory-tier-boundary-guard.test.ts +1 -1
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +9 -4
- package/src/plugins/defaults/memory/host-utils.ts +5 -0
- package/src/plugins/defaults/memory/memory-retrospective-accounting.ts +105 -1
- package/src/plugins/defaults/memory/memory-retrospective-enqueue.ts +64 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +36 -1
- package/src/plugins/defaults/memory/memory-retrospective-sweep.ts +12 -3
- package/src/plugins/defaults/memory/src/__tests__/memory-v2-simulate-route.test.ts +1 -0
- package/src/plugins/defaults/memory/substrate/__tests__/skill-store.test.ts +195 -1
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +1 -1
- package/src/plugins/defaults/memory/substrate/skill-store.ts +167 -29
- package/src/plugins/defaults/memory/v2/__tests__/injection.test.ts +131 -0
- package/src/plugins/defaults/memory/v2/__tests__/router.test.ts +1 -0
- package/src/plugins/defaults/memory/v2/activation-log-store.ts +4 -0
- package/src/plugins/defaults/memory/v2/injection.ts +48 -3
- package/src/plugins/defaults/memory/v2/rerank-local.ts +6 -2
- package/src/plugins/defaults/memory/v3/__tests__/pool-select.test.ts +1 -1
- package/src/plugins/defaults/platform-hosted/routes/reengage.ts +1 -1
- package/src/plugins/defaults/turn-context/injectors.ts +1 -0
- package/src/plugins/defaults/turn-context/unified-turn-context.ts +26 -0
- package/src/plugins/types.ts +17 -0
- package/src/prompts/templates/system-sections.ts +2 -2
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/model-catalog.ts +0 -11
- package/src/providers/model-intents.ts +11 -0
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/providers/types.ts +8 -1
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/agent-wake.ts +132 -9
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/__tests__/connection-routes-vs-cli-parity.test.ts +2 -2
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +11 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +121 -9
- package/src/runtime/routes/__tests__/platform-invoice-routes.test.ts +392 -0
- package/src/runtime/routes/__tests__/slack-channel-routes.test.ts +7 -0
- package/src/runtime/routes/__tests__/workspace-commit-routes.test.ts +63 -0
- package/src/runtime/routes/consolidation-routes.ts +1 -1
- package/src/runtime/routes/conversation-management-routes.ts +18 -6
- package/src/runtime/routes/conversation-query-routes.ts +14 -23
- package/src/runtime/routes/conversation-routes.ts +113 -8
- package/src/runtime/routes/credential-routes.ts +1 -5
- package/src/runtime/routes/documents-routes.ts +207 -12
- package/src/runtime/routes/identity-routes.ts +3 -93
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +31 -1
- package/src/runtime/routes/inbound-stages/admission-policy.ts +18 -0
- package/src/runtime/routes/inference-provider-connection-routes.ts +52 -3
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/runtime/routes/platform-routes.ts +238 -3
- package/src/runtime/routes/playground/guard.ts +1 -2
- package/src/runtime/routes/slack-channel-routes.ts +2 -4
- package/src/runtime/routes/workspace-commit-routes.ts +49 -2
- package/src/runtime/routes/workspace-routes.ts +11 -12
- package/src/runtime/routes/workspace-utils.ts +49 -2
- package/src/runtime/services/conversation-serializer.ts +2 -4
- package/src/subagent/__tests__/consult-context-gating.test.ts +98 -0
- package/src/subagent/__tests__/consult-context-skills.test.ts +82 -0
- package/src/subagent/__tests__/consult-context.test.ts +84 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +36 -0
- package/src/subagent/consult-context.ts +410 -0
- package/src/subagent/consult-prompt.ts +38 -5
- package/src/subagent/manager.ts +9 -4
- package/src/subagent/types.ts +8 -0
- package/src/telemetry/telemetry-event-sources.ts +7 -0
- package/src/telemetry/telemetry-wire-source.json +1 -1
- package/src/telemetry/telemetry-wire.generated.ts +1 -0
- package/src/telemetry/turn-events-store.ts +30 -1
- package/src/telemetry/types.ts +24 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +48 -0
- package/src/tools/browser/pinned-tabs.ts +3 -1
- package/src/tools/subagent/spawn.ts +23 -1
- package/src/tools/terminal/safe-env.ts +1 -0
- package/src/tools/ui-surface/definitions.ts +39 -1
- package/src/tools/ui-surface/surface-shape-docs.ts +38 -4
- package/src/tools/ui-surface/visual-validation.ts +787 -0
- package/src/tools/workflows/run-workflow.test.ts +1 -0
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/__tests__/worker-compute.test.ts +65 -0
- package/src/util/cgroup-cpu.ts +93 -0
- package/src/util/errors.ts +27 -0
- package/src/util/process-tree.ts +19 -0
- package/src/util/short-title.ts +189 -0
- package/src/util/worker-compute.ts +83 -0
- package/src/workspace/byok-default-profile-ensure.ts +25 -15
- package/src/workspace/git-service.ts +79 -30
- package/src/workspace/migrations/137-repair-retired-fireworks-minimax-model-id.ts +131 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/139-clear-renamed-cost-profile-label.ts +92 -0
- package/src/workspace/migrations/registry.ts +6 -0
- package/src/plugins/defaults/memory/substrate/constants.ts +0 -8
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the model-invokable `visual` ui_show surface.
|
|
3
|
+
*
|
|
4
|
+
* Two layers are covered. The tool-level guards (`definitions.ts`) reject a
|
|
5
|
+
* fragment the sandbox would fail silently on — external resources, colour
|
|
6
|
+
* literals, invented `var()` names — before anything is proxied. The daemon
|
|
7
|
+
* resolver then emits the surface: the `ui_surface_show` it sends must
|
|
8
|
+
* validate against the canonical event schema, because the client's stream
|
|
9
|
+
* parser silently drops events that do not.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
|
|
16
|
+
import { UISurfaceShowEventSchema } from "../api/events/ui-surface-show.js";
|
|
17
|
+
import type { AssistantEvent } from "../api/index.js";
|
|
18
|
+
import {
|
|
19
|
+
createSurfaceMutex,
|
|
20
|
+
type SurfaceConversationContext,
|
|
21
|
+
surfaceProxyResolver,
|
|
22
|
+
} from "../daemon/conversation-surfaces.js";
|
|
23
|
+
import type { SurfaceType } from "../daemon/message-protocol.js";
|
|
24
|
+
import { INTERACTIVE_SURFACE_TYPES } from "../daemon/message-protocol.js";
|
|
25
|
+
import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
|
|
26
|
+
import { uiShowTool } from "../tools/ui-surface/definitions.js";
|
|
27
|
+
import { validateVisualHtml } from "../tools/ui-surface/visual-validation.js";
|
|
28
|
+
|
|
29
|
+
const HTML = '<div style="color:var(--content-default)">Hello</div>';
|
|
30
|
+
|
|
31
|
+
function makeContext(sent: AssistantEvent[] = []): SurfaceConversationContext {
|
|
32
|
+
return {
|
|
33
|
+
conversationId: "session-1",
|
|
34
|
+
sendToClient: (msg) => sent.push(msg),
|
|
35
|
+
pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
|
|
36
|
+
lastSurfaceAction: new Map<
|
|
37
|
+
string,
|
|
38
|
+
{ actionId: string; data?: Record<string, unknown> }
|
|
39
|
+
>(),
|
|
40
|
+
surfaceState: new Map(),
|
|
41
|
+
surfaceUndoStacks: new Map<string, string[]>(),
|
|
42
|
+
accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
|
|
43
|
+
surfaceActionRequestIds: new Set<string>(),
|
|
44
|
+
currentTurnSurfaces: [],
|
|
45
|
+
isProcessing: () => false,
|
|
46
|
+
enqueueMessage: () => ({ queued: false, requestId: "req-1" }),
|
|
47
|
+
getQueueDepth: () => 0,
|
|
48
|
+
processMessage: async () => "ok",
|
|
49
|
+
withSurface: createSurfaceMutex(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeToolContext(onProxy?: () => void): ToolContext {
|
|
54
|
+
return {
|
|
55
|
+
conversationId: "conversation-123",
|
|
56
|
+
workingDir: "/tmp",
|
|
57
|
+
trustClass: "guardian",
|
|
58
|
+
proxyToolResolver: async (): Promise<ToolExecutionResult> => {
|
|
59
|
+
onProxy?.();
|
|
60
|
+
return { content: "Surface displayed", isError: false };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Run ui_show and report whether the payload reached the proxy. */
|
|
66
|
+
async function runUiShow(
|
|
67
|
+
input: Record<string, unknown>,
|
|
68
|
+
): Promise<{ result: ToolExecutionResult; proxied: boolean }> {
|
|
69
|
+
let proxied = false;
|
|
70
|
+
const result = await uiShowTool.execute(
|
|
71
|
+
input,
|
|
72
|
+
makeToolContext(() => {
|
|
73
|
+
proxied = true;
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
return { result, proxied };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Tool definition
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
describe("ui_show advertises visual", () => {
|
|
84
|
+
test("the surface_type enum includes visual", () => {
|
|
85
|
+
const surfaceTypeEnum = (
|
|
86
|
+
uiShowTool.input_schema as {
|
|
87
|
+
properties: { surface_type: { enum: string[] } };
|
|
88
|
+
}
|
|
89
|
+
).properties.surface_type.enum;
|
|
90
|
+
|
|
91
|
+
expect(surfaceTypeEnum).toContain("visual");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the description points at the visualize skill in one index line", () => {
|
|
95
|
+
expect(uiShowTool.description).toContain(
|
|
96
|
+
"visual (polished inline diagram/chart/explainer — PREFER this when explaining how something works or compares; load the `visualize` skill first)",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("visual is display-only, not an interactive surface", () => {
|
|
101
|
+
expect(INTERACTIVE_SURFACE_TYPES).not.toContain("visual");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Fragment guards
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("ui_show visual fragment guards", () => {
|
|
110
|
+
test("rejects an empty fragment without proxying", async () => {
|
|
111
|
+
const { result, proxied } = await runUiShow({
|
|
112
|
+
surface_type: "visual",
|
|
113
|
+
data: { html: " " },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(result.isError).toBe(true);
|
|
117
|
+
expect(result.content).toContain("non-empty HTML fragment in `data.html`");
|
|
118
|
+
expect(proxied).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rejects an oversized fragment with a simplification hint", async () => {
|
|
122
|
+
const { result, proxied } = await runUiShow({
|
|
123
|
+
surface_type: "visual",
|
|
124
|
+
data: { html: "<p>x</p>".repeat(8000) },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.isError).toBe(true);
|
|
128
|
+
expect(result.content).toContain("Simplify");
|
|
129
|
+
expect(result.content).toContain("Fix every item above");
|
|
130
|
+
expect(proxied).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("rejects fragments that reach for external resources", async () => {
|
|
134
|
+
for (const bad of [
|
|
135
|
+
'<script src="https://cdn.example.com/chart.js"></script>',
|
|
136
|
+
'<link rel="stylesheet" href="https://cdn.example.com/a.css">',
|
|
137
|
+
'<style>@import url("https://cdn.example.com/a.css");</style>',
|
|
138
|
+
]) {
|
|
139
|
+
const { result, proxied } = await runUiShow({
|
|
140
|
+
surface_type: "visual",
|
|
141
|
+
data: { html: bad },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.isError).toBe(true);
|
|
145
|
+
expect(result.content).toContain("no network access");
|
|
146
|
+
expect(proxied).toBe(false);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("rejects hardcoded hex and functional colour literals together", async () => {
|
|
151
|
+
const { result, proxied } = await runUiShow({
|
|
152
|
+
surface_type: "visual",
|
|
153
|
+
data: {
|
|
154
|
+
html: '<div style="background:#eff6ff;color:#2563eb;border-color:rgba(255, 255, 255, 0.1)">Hi</div>',
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.isError).toBe(true);
|
|
159
|
+
expect(result.content).toContain("#eff6ff");
|
|
160
|
+
expect(result.content).toContain("#2563eb");
|
|
161
|
+
expect(result.content).toContain("rgba(255, 255, 255, 0.1)");
|
|
162
|
+
expect(proxied).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("rejects an invented var() name and enumerates the real vocabulary", async () => {
|
|
166
|
+
const { result, proxied } = await runUiShow({
|
|
167
|
+
surface_type: "visual",
|
|
168
|
+
data: { html: '<div style="color:var(--color-text-primary)">Hi</div>' },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(result.isError).toBe(true);
|
|
172
|
+
expect(result.content).toContain("--color-text-primary");
|
|
173
|
+
expect(result.content).toContain("--content-default");
|
|
174
|
+
expect(result.content).toContain("--color-<moss|stone>-<50-950>");
|
|
175
|
+
expect(result.content).toContain(
|
|
176
|
+
"--color-<forest|emerald|danger|amber>-<100-950>",
|
|
177
|
+
);
|
|
178
|
+
expect(proxied).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("does not mistake SVG references, anchors, or entities for colours", async () => {
|
|
182
|
+
const { result, proxied } = await runUiShow({
|
|
183
|
+
surface_type: "visual",
|
|
184
|
+
data: {
|
|
185
|
+
html:
|
|
186
|
+
'<svg role="img" viewBox="0 0 10 10"><title>t</title>' +
|
|
187
|
+
'<defs><marker id="abc"><path d="M0 0" fill="var(--content-default)"/></marker>' +
|
|
188
|
+
'<linearGradient id="face"><stop stop-color="var(--color-moss-200)"/></linearGradient></defs>' +
|
|
189
|
+
'<line x1="0" y1="0" x2="10" y2="0" marker-end="url(#abc)" stroke="url(#face)"/></svg>' +
|
|
190
|
+
'<a href="#bad">Jump ↗</a>',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.isError).toBe(false);
|
|
195
|
+
expect(proxied).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("accepts custom properties the fragment declares itself", async () => {
|
|
199
|
+
const { result, proxied } = await runUiShow({
|
|
200
|
+
surface_type: "visual",
|
|
201
|
+
data: {
|
|
202
|
+
html:
|
|
203
|
+
"<style>:root{--gap:8px}.row{gap:var(--gap)}</style>" +
|
|
204
|
+
'<div class="row" style="color:var(--content-default)">Hi</div>',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(result.isError).toBe(false);
|
|
209
|
+
expect(proxied).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("accepts a custom property the fragment sets from script", async () => {
|
|
213
|
+
const { result, proxied } = await runUiShow({
|
|
214
|
+
surface_type: "visual",
|
|
215
|
+
data: {
|
|
216
|
+
html:
|
|
217
|
+
'<div id="grid" style="width:var(--col)">Hi</div>' +
|
|
218
|
+
"<script>document.getElementById('grid').style.setProperty('--col', '120px');</script>",
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.isError).toBe(false);
|
|
223
|
+
expect(proxied).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("validates a fragment the model placed at the top level", async () => {
|
|
227
|
+
const { result, proxied } = await runUiShow({
|
|
228
|
+
surface_type: "visual",
|
|
229
|
+
html: '<div style="color:#fff">Hi</div>',
|
|
230
|
+
data: {},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
expect(result.isError).toBe(true);
|
|
234
|
+
expect(result.content).toContain("#fff");
|
|
235
|
+
expect(proxied).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("rejects a bare label whose only same-palette fill is a distant pill", async () => {
|
|
239
|
+
// The fragment that rendered at 1.04:1 in dark mode: two labels painted
|
|
240
|
+
// directly on the transparent background, with the matching light stops
|
|
241
|
+
// used as pill fills several hundred characters away.
|
|
242
|
+
const pill = (palette: string): string =>
|
|
243
|
+
`<span style="background:var(--color-${palette}-100);color:var(--color-${palette}-900);` +
|
|
244
|
+
'border-radius:var(--radius-pill);padding:2px 8px">tag</span>';
|
|
245
|
+
const { result, proxied } = await runUiShow({
|
|
246
|
+
surface_type: "visual",
|
|
247
|
+
data: {
|
|
248
|
+
html:
|
|
249
|
+
'<svg role="img" viewBox="0 0 680 220"><title>Lookup</title><desc>Walk</desc>' +
|
|
250
|
+
'<rect x="40" y="30" width="120" height="44" rx="4" fill="var(--surface-lift)" stroke="var(--border-element)"/>' +
|
|
251
|
+
'<rect x="180" y="30" width="120" height="44" rx="4" fill="var(--surface-lift)" stroke="var(--border-element)"/>' +
|
|
252
|
+
'<rect x="320" y="30" width="120" height="44" rx="4" fill="var(--surface-lift)" stroke="var(--border-element)"/>' +
|
|
253
|
+
'<text x="100" y="52" dominant-baseline="central" font-size="12" fill="var(--content-default)">key</text>' +
|
|
254
|
+
'<text x="240" y="52" dominant-baseline="central" font-size="12" fill="var(--content-default)">hash</text>' +
|
|
255
|
+
'<text x="390" y="70" font-size="12" fill="var(--color-forest-900)">found!</text>' +
|
|
256
|
+
'<text x="470" y="70" font-size="12" fill="var(--color-danger-900)">check next</text></svg>' +
|
|
257
|
+
'<div style="padding:0.5rem 0;font:400 12px var(--font-sans);color:var(--content-secondary)">' +
|
|
258
|
+
"Buckets hold one entry each; a collision walks forward to the next free slot. " +
|
|
259
|
+
"Load factor stays under 0.7 so the walk stays short and lookups stay near constant time. " +
|
|
260
|
+
"The table doubles when it crosses that line, which rehashes every key once.</div>" +
|
|
261
|
+
`<div style="display:flex;gap:12px">${pill("forest")}${pill("danger")}</div>`,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(result.isError).toBe(true);
|
|
266
|
+
expect(result.content).toContain("--color-forest-900");
|
|
267
|
+
expect(result.content).toContain("--color-danger-900");
|
|
268
|
+
expect(result.content).toContain("mirror across their own ramp");
|
|
269
|
+
expect(proxied).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("accepts a label painted beside its box in the same group", async () => {
|
|
273
|
+
const { result, proxied } = await runUiShow({
|
|
274
|
+
surface_type: "visual",
|
|
275
|
+
data: {
|
|
276
|
+
html:
|
|
277
|
+
'<svg role="img" viewBox="0 0 680 120"><title>t</title><desc>d</desc>' +
|
|
278
|
+
'<g><rect x="40" y="30" width="180" height="44" rx="4" fill="var(--color-forest-100)"/>' +
|
|
279
|
+
'<text x="130" y="52" text-anchor="middle" dominant-baseline="central" ' +
|
|
280
|
+
'fill="var(--color-forest-900)">Ingest</text></g></svg>',
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(result.isError).toBe(false);
|
|
285
|
+
expect(proxied).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("accepts a label whose box fill comes from the group's class rule", async () => {
|
|
289
|
+
const { result, proxied } = await runUiShow({
|
|
290
|
+
surface_type: "visual",
|
|
291
|
+
data: {
|
|
292
|
+
html:
|
|
293
|
+
'<svg role="img" viewBox="0 0 680 80"><title>t</title><desc>d</desc>' +
|
|
294
|
+
"<style>.bx rect{fill:var(--color-emerald-100);stroke:var(--color-emerald-600)}</style>" +
|
|
295
|
+
'<g class="bx"><rect x="0" y="0" width="200" height="60" rx="4"/>' +
|
|
296
|
+
'<text x="100" y="30" fill="var(--color-emerald-900)">Worker</text></g></svg>',
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(result.isError).toBe(false);
|
|
301
|
+
expect(proxied).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("rejects dark ramp text with no light fill of the same palette", async () => {
|
|
305
|
+
const { result, proxied } = await runUiShow({
|
|
306
|
+
surface_type: "visual",
|
|
307
|
+
data: {
|
|
308
|
+
html:
|
|
309
|
+
'<svg role="img" viewBox="0 0 680 80"><title>t</title><desc>d</desc>' +
|
|
310
|
+
"<style>.th{font-size:14px;fill:var(--color-forest-900)}</style>" +
|
|
311
|
+
'<text class="th" x="10" y="40">Gateway</text>' +
|
|
312
|
+
'<text x="10" y="60" fill="var(--color-forest-800)">Routes traffic</text></svg>',
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(result.isError).toBe(true);
|
|
317
|
+
expect(result.content).toContain("--color-forest-900");
|
|
318
|
+
expect(result.content).toContain("--color-forest-800");
|
|
319
|
+
expect(result.content).toContain("mirror across their own ramp");
|
|
320
|
+
expect(result.content).toContain("--content-*");
|
|
321
|
+
expect(proxied).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Which elements a selector reaches is not knowable from the markup, so a
|
|
325
|
+
// fill anywhere in the fragment pairs a stylesheet-painted label.
|
|
326
|
+
test("accepts stylesheet-painted text once a matched light fill is present", async () => {
|
|
327
|
+
const { result, proxied } = await runUiShow({
|
|
328
|
+
surface_type: "visual",
|
|
329
|
+
data: {
|
|
330
|
+
html:
|
|
331
|
+
'<svg role="img" viewBox="0 0 680 80"><title>t</title><desc>d</desc>' +
|
|
332
|
+
"<style>.th{font-size:14px;fill:var(--color-forest-900)}</style>" +
|
|
333
|
+
'<rect x="0" y="0" width="200" height="60" fill="var(--color-forest-100)"/>' +
|
|
334
|
+
'<text class="th" x="10" y="30">Gateway</text></svg>',
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(result.isError).toBe(false);
|
|
339
|
+
expect(proxied).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("accepts a matched triple written in CSS on an HTML card", async () => {
|
|
343
|
+
const { result, proxied } = await runUiShow({
|
|
344
|
+
surface_type: "visual",
|
|
345
|
+
data: {
|
|
346
|
+
html:
|
|
347
|
+
"<style>.c{background:var(--color-amber-100);border:1px solid var(--color-amber-600);" +
|
|
348
|
+
"color:var(--color-amber-900)}</style>" +
|
|
349
|
+
'<div class="c">Warm path</div>',
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(result.isError).toBe(false);
|
|
354
|
+
expect(proxied).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("leaves theme-aware content tokens alone", async () => {
|
|
358
|
+
const { result, proxied } = await runUiShow({
|
|
359
|
+
surface_type: "visual",
|
|
360
|
+
data: {
|
|
361
|
+
html:
|
|
362
|
+
'<svg role="img" viewBox="0 0 680 80"><title>t</title><desc>d</desc>' +
|
|
363
|
+
"<style>.th{font-size:14px;fill:var(--content-strong)}</style>" +
|
|
364
|
+
'<text class="th" x="10" y="40">Gateway</text></svg>',
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
expect(result.isError).toBe(false);
|
|
369
|
+
expect(proxied).toBe(true);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("rejects light ramp text with no dark fill of the same palette", async () => {
|
|
373
|
+
const { result, proxied } = await runUiShow({
|
|
374
|
+
surface_type: "visual",
|
|
375
|
+
data: {
|
|
376
|
+
html: '<div style="color:var(--color-stone-100)">Quiet label</div>',
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
expect(result.isError).toBe(true);
|
|
381
|
+
expect(result.content).toContain("--color-stone-100");
|
|
382
|
+
expect(result.content).toContain("invisible in light mode");
|
|
383
|
+
expect(proxied).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("does not mistake background-color for a text colour", async () => {
|
|
387
|
+
const { result, proxied } = await runUiShow({
|
|
388
|
+
surface_type: "visual",
|
|
389
|
+
data: {
|
|
390
|
+
html:
|
|
391
|
+
'<div style="background-color:var(--color-danger-900);' +
|
|
392
|
+
'color:var(--content-inset)">Failed</div>',
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
expect(result.isError).toBe(false);
|
|
397
|
+
expect(proxied).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("the app-substitute guard does not fire for visual", async () => {
|
|
401
|
+
const html =
|
|
402
|
+
'<div id="root" style="color:var(--content-default)"></div><script>' +
|
|
403
|
+
"const data=[/*...*/];".padEnd(2100, "/") +
|
|
404
|
+
"document.getElementById('root').textContent='ok';</script>";
|
|
405
|
+
const { result, proxied } = await runUiShow({
|
|
406
|
+
surface_type: "visual",
|
|
407
|
+
title: "Labor Market Stats dashboard",
|
|
408
|
+
data: { html },
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(result.isError).toBe(false);
|
|
412
|
+
expect(result.content).not.toContain("app-builder");
|
|
413
|
+
expect(proxied).toBe(true);
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// SVG sizing
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
describe("an svg that cannot scale to the frame", () => {
|
|
422
|
+
test("a root svg with no viewBox names the fix", () => {
|
|
423
|
+
const problems = validateVisualHtml(
|
|
424
|
+
'<svg width="900" height="400"><rect x="0" y="0" width="120" height="40" fill="var(--surface-lift)"/></svg>',
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
expect(problems.some((p) => p.includes("no viewBox attribute"))).toBe(true);
|
|
428
|
+
expect(problems.join(" ")).toContain('viewBox="0 0 W H"');
|
|
429
|
+
expect(problems.join(" ")).toContain('width="100%"');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("a viewBox-less svg sized past the frame is flagged for its width", () => {
|
|
433
|
+
const problems = validateVisualHtml(
|
|
434
|
+
'<svg width="900" height="400"><rect x="0" y="0" width="120" height="40" fill="var(--surface-lift)"/></svg>',
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const oversized = problems.find((p) => p.includes("sized past"));
|
|
438
|
+
expect(oversized).toBeDefined();
|
|
439
|
+
expect(oversized).toContain('width="900"');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("a viewBox is enough: the pixel sizes are only read without one", () => {
|
|
443
|
+
expect(
|
|
444
|
+
validateVisualHtml(
|
|
445
|
+
'<svg width="100%" viewBox="0 0 680 340"><rect x="0" y="0" width="120" height="40" fill="var(--surface-lift)"/></svg>',
|
|
446
|
+
),
|
|
447
|
+
).toEqual([]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("a shape drawn past the viewBox is reported with its overrun", () => {
|
|
451
|
+
const problems = validateVisualHtml(
|
|
452
|
+
'<svg width="100%" viewBox="0 0 680 340"><rect x="610" y="40" width="90" height="56" fill="var(--surface-lift)"/></svg>',
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
expect(problems).toHaveLength(1);
|
|
456
|
+
expect(problems[0]).toContain("outside the viewBox");
|
|
457
|
+
expect(problems[0]).toContain("ends at 700, past 680");
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe("coordinate-system guards", () => {
|
|
462
|
+
test("a transform attribute is rejected with the absolute-coordinates fix", () => {
|
|
463
|
+
const problems = validateVisualHtml(
|
|
464
|
+
'<svg width="100%" viewBox="0 0 680 200"><g transform="translate(30 55)"><rect width="140" height="56" fill="var(--surface-lift)"/><text x="70" y="77" fill="var(--content-strong)">Browser</text></g></svg>',
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(problems).toHaveLength(1);
|
|
468
|
+
expect(problems[0]).toContain("transform attribute");
|
|
469
|
+
expect(problems[0]).toContain("absolute viewBox coordinate");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("transform-free absolute placement passes", () => {
|
|
473
|
+
expect(
|
|
474
|
+
validateVisualHtml(
|
|
475
|
+
'<svg width="100%" viewBox="0 0 680 200"><g><rect x="30" y="55" width="140" height="56" fill="var(--surface-lift)"/><text x="100" y="77" text-anchor="middle" fill="var(--content-strong)">Browser</text></g></svg>',
|
|
476
|
+
),
|
|
477
|
+
).toEqual([]);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("a group-level surface fill that would repaint text is rejected", () => {
|
|
481
|
+
const problems = validateVisualHtml(
|
|
482
|
+
'<style>.th{fill:var(--content-strong)}.note{fill:var(--surface-sunken)}</style><svg width="100%" viewBox="0 0 680 120"><g class="note"><rect x="120" y="20" width="440" height="54"/><text class="th" x="340" y="47">Caching makes repeat visits fast</text></g></svg>',
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(problems).toHaveLength(1);
|
|
486
|
+
expect(problems[0]).toContain(".note");
|
|
487
|
+
expect(problems[0]).toContain("fill inherits to text");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("a surface fill scoped to the shape passes", () => {
|
|
491
|
+
expect(
|
|
492
|
+
validateVisualHtml(
|
|
493
|
+
'<style>.th{fill:var(--content-strong)}.note rect{fill:var(--surface-sunken);stroke:var(--border-subtle)}</style><svg width="100%" viewBox="0 0 680 120"><g class="note"><rect x="120" y="20" width="440" height="54"/><text class="th" x="340" y="47">Caching makes repeat visits fast</text></g></svg>',
|
|
494
|
+
),
|
|
495
|
+
).toEqual([]);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// Skill reference examples
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
describe("the visualize skill examples pass the fragment guards", () => {
|
|
504
|
+
const skillFile = join(
|
|
505
|
+
import.meta.dir,
|
|
506
|
+
"../config/bundled-skills/visualize/SKILL.md",
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const source = readFileSync(skillFile, "utf8");
|
|
510
|
+
const examples = [...source.matchAll(/```(\w*)\n([\s\S]*?)```/g)].map(
|
|
511
|
+
(match, index) => ({
|
|
512
|
+
name: `SKILL.md example ${index + 1} (${match[1] || "text"})`,
|
|
513
|
+
body: match[2],
|
|
514
|
+
}),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
test("every fenced example is present", () => {
|
|
518
|
+
expect(examples.length).toBe(6);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
for (const example of examples) {
|
|
522
|
+
test(`${example.name} is accepted verbatim`, () => {
|
|
523
|
+
expect(validateVisualHtml(example.body)).toEqual([]);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// Stringified `data`
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
describe("ui_show data double-encoded as a JSON string", () => {
|
|
533
|
+
test("a string that decodes to an object is still accepted", async () => {
|
|
534
|
+
const { result, proxied } = await runUiShow({
|
|
535
|
+
surface_type: "visual",
|
|
536
|
+
data: JSON.stringify({ html: HTML, height: 320 }),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
expect(result.isError).toBe(false);
|
|
540
|
+
expect(proxied).toBe(true);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("a string that fails to decode names the parse failure, not empty html", async () => {
|
|
544
|
+
// The shape the model actually produced: HTML attributes quoted with `"`
|
|
545
|
+
// inside a JSON string, escaped wrong.
|
|
546
|
+
const { result, proxied } = await runUiShow({
|
|
547
|
+
surface_type: "visual",
|
|
548
|
+
data: '{"html": "<div style=\\"color:var(--content-default)\\">Hi</div>"',
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
expect(result.isError).toBe(true);
|
|
552
|
+
const content = result.content as string;
|
|
553
|
+
expect(content).toContain(
|
|
554
|
+
"`data` arrived as a JSON-encoded string that could not be parsed",
|
|
555
|
+
);
|
|
556
|
+
expect(content).toContain(
|
|
557
|
+
"Pass `data` as a JSON object rather than a string",
|
|
558
|
+
);
|
|
559
|
+
expect(content).toContain("use single quotes for the HTML attributes");
|
|
560
|
+
expect(content).toContain(
|
|
561
|
+
"Resend the same ui_show with `data` as an object",
|
|
562
|
+
);
|
|
563
|
+
// The misleading cause the model chased for eight minutes in E2E.
|
|
564
|
+
expect(content).not.toContain("non-empty HTML fragment in `data.html`");
|
|
565
|
+
expect(proxied).toBe(false);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("the parse error itself is quoted back so the model can locate it", async () => {
|
|
569
|
+
const { result } = await runUiShow({
|
|
570
|
+
surface_type: "visual",
|
|
571
|
+
data: '{"html": "<p>x</p>", }',
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Whatever the engine's SyntaxError wording, its text is carried through.
|
|
575
|
+
let parseMessage = "";
|
|
576
|
+
try {
|
|
577
|
+
JSON.parse('{"html": "<p>x</p>", }');
|
|
578
|
+
} catch (error) {
|
|
579
|
+
parseMessage = (error as Error).message;
|
|
580
|
+
}
|
|
581
|
+
expect(parseMessage.length).toBeGreaterThan(0);
|
|
582
|
+
expect(result.content).toContain(parseMessage);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("dynamic_page gets the same envelope instead of the empty-html hint", async () => {
|
|
586
|
+
const { result, proxied } = await runUiShow({
|
|
587
|
+
surface_type: "dynamic_page",
|
|
588
|
+
data: '{"html": "<section class=\\"a\\">"',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(result.isError).toBe(true);
|
|
592
|
+
const content = result.content as string;
|
|
593
|
+
expect(content).toContain(
|
|
594
|
+
"`data` arrived as a JSON-encoded string that could not be parsed",
|
|
595
|
+
);
|
|
596
|
+
expect(content).not.toContain("requires non-empty HTML in `data.html`");
|
|
597
|
+
expect(proxied).toBe(false);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Daemon emission
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
describe("ui_show visual emission", () => {
|
|
606
|
+
test("emits a schema-valid inline visual surface and returns its id", async () => {
|
|
607
|
+
const sent: AssistantEvent[] = [];
|
|
608
|
+
const ctx = makeContext(sent);
|
|
609
|
+
|
|
610
|
+
const result = await surfaceProxyResolver(ctx, "ui_show", {
|
|
611
|
+
surface_type: "visual",
|
|
612
|
+
title: "How staging works",
|
|
613
|
+
data: { html: HTML, height: 320 },
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
expect(result.isError).toBe(false);
|
|
617
|
+
expect(result.yieldToUser).toBeUndefined();
|
|
618
|
+
|
|
619
|
+
const show = sent.find((msg) => msg.type === "ui_surface_show");
|
|
620
|
+
expect(show).toBeDefined();
|
|
621
|
+
const parsed = UISurfaceShowEventSchema.parse(show);
|
|
622
|
+
expect(parsed.conversationId).toBe("session-1");
|
|
623
|
+
expect(parsed.surfaceType).toBe("visual");
|
|
624
|
+
expect(parsed.display).toBe("inline");
|
|
625
|
+
expect(parsed.title).toBe("How staging works");
|
|
626
|
+
expect(parsed.data).toEqual({ html: HTML, height: 320 });
|
|
627
|
+
|
|
628
|
+
const payload = JSON.parse(result.content) as {
|
|
629
|
+
surfaceId: string;
|
|
630
|
+
note: string;
|
|
631
|
+
};
|
|
632
|
+
expect(payload.surfaceId).toBe(parsed.surfaceId);
|
|
633
|
+
expect(payload.note).toContain("Continue your response in prose");
|
|
634
|
+
expect(payload.note).toContain("ui_dismiss");
|
|
635
|
+
expect(ctx.pendingSurfaceActions.has(parsed.surfaceId)).toBe(false);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("lifts a top-level html and height into the surface data", async () => {
|
|
639
|
+
const sent: AssistantEvent[] = [];
|
|
640
|
+
|
|
641
|
+
await surfaceProxyResolver(makeContext(sent), "ui_show", {
|
|
642
|
+
surface_type: "visual",
|
|
643
|
+
html: HTML,
|
|
644
|
+
height: 240,
|
|
645
|
+
data: {},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const parsed = UISurfaceShowEventSchema.parse(
|
|
649
|
+
sent.find((msg) => msg.type === "ui_surface_show"),
|
|
650
|
+
);
|
|
651
|
+
expect(parsed.data).toEqual({ html: HTML, height: 240 });
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("clamps an out-of-range height estimate", async () => {
|
|
655
|
+
const sent: AssistantEvent[] = [];
|
|
656
|
+
|
|
657
|
+
await surfaceProxyResolver(makeContext(sent), "ui_show", {
|
|
658
|
+
surface_type: "visual",
|
|
659
|
+
data: { html: HTML, height: 99999 },
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const parsed = UISurfaceShowEventSchema.parse(
|
|
663
|
+
sent.find((msg) => msg.type === "ui_surface_show"),
|
|
664
|
+
);
|
|
665
|
+
expect(parsed.data.height).toBe(1400);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("omits height when the caller supplies none", async () => {
|
|
669
|
+
const sent: AssistantEvent[] = [];
|
|
670
|
+
|
|
671
|
+
await surfaceProxyResolver(makeContext(sent), "ui_show", {
|
|
672
|
+
surface_type: "visual",
|
|
673
|
+
data: { html: HTML },
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const parsed = UISurfaceShowEventSchema.parse(
|
|
677
|
+
sent.find((msg) => msg.type === "ui_surface_show"),
|
|
678
|
+
);
|
|
679
|
+
expect(parsed.data).toEqual({ html: HTML });
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("a pending visual does not hold the one-interactive-surface lock", async () => {
|
|
683
|
+
const sent: AssistantEvent[] = [];
|
|
684
|
+
const ctx = makeContext(sent);
|
|
685
|
+
ctx.pendingSurfaceActions.set("surface-visual", { surfaceType: "visual" });
|
|
686
|
+
|
|
687
|
+
const result = await surfaceProxyResolver(ctx, "ui_show", {
|
|
688
|
+
surface_type: "choice",
|
|
689
|
+
data: { options: [{ id: "a", title: "Option A" }] },
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
expect(result.isError).toBe(false);
|
|
693
|
+
expect(result.content).not.toContain("already awaiting user input");
|
|
694
|
+
});
|
|
695
|
+
});
|