@happyvertical/smrt-chat 0.57.5 → 0.57.7

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.
Files changed (29) hide show
  1. package/AGENTS.md +2 -0
  2. package/agents/assistant-questions.md +39 -0
  3. package/dist/chunks/{chat-stream-CXS-F9In.js → chat-stream-CfZelFch.js} +2 -2
  4. package/dist/chunks/{chat-stream-CXS-F9In.js.map → chat-stream-CfZelFch.js.map} +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/manifest.json +1 -1
  7. package/dist/smrt-knowledge.json +11 -5
  8. package/dist/svelte/components/assistant/AssistantDock.svelte +84 -20
  9. package/dist/svelte/components/assistant/AssistantDock.svelte.d.ts.map +1 -1
  10. package/dist/svelte/components/assistant/AssistantQuestionForm.svelte +245 -0
  11. package/dist/svelte/components/assistant/AssistantQuestionForm.svelte.d.ts +17 -0
  12. package/dist/svelte/components/assistant/AssistantQuestionForm.svelte.d.ts.map +1 -0
  13. package/dist/svelte/components/assistant/assistant-question.d.ts +88 -0
  14. package/dist/svelte/components/assistant/assistant-question.d.ts.map +1 -0
  15. package/dist/svelte/components/assistant/assistant-question.js +222 -0
  16. package/dist/svelte/components/assistant/assistant-transport.d.ts +7 -1
  17. package/dist/svelte/components/assistant/assistant-transport.d.ts.map +1 -1
  18. package/dist/svelte/components/assistant/assistant-transport.js +0 -9
  19. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts +9 -0
  20. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.d.ts.map +1 -1
  21. package/dist/svelte/components/assistant/create-assistant-dock-controller.svelte.js +46 -0
  22. package/dist/svelte/i18n.d.ts +2 -0
  23. package/dist/svelte/i18n.d.ts.map +1 -1
  24. package/dist/svelte/i18n.js +3 -0
  25. package/dist/svelte/index.d.ts +2 -0
  26. package/dist/svelte/index.d.ts.map +1 -1
  27. package/dist/svelte/index.js +2 -0
  28. package/dist/sveltekit.js +1 -1
  29. package/package.json +17 -17
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { c as ChatThread, d as ChatParticipant, f as ChatMessage, i as clientRequestMessageId, l as ChatRoom, n as ChatService, o as VoiceSessionCollection, p as AgentSession, s as VoiceSession, t as ChatClientRequestConflictError, u as ChatReaction } from "./chunks/ChatService-DjGf2AoJ.js";
2
- import { A as MAX_CLIENT_TOOLS, B as RUNTIME_REPORT_DEFINE_FUNCTION_NAME, C as SESSION_CONTINUATIONS_FIELD, D as runAssistantTurn, E as createSessionContinuationStore, F as classifyToolError, G as RUNTIME_REPORT_RUN_TOOL_SLUG, H as RUNTIME_REPORT_LIST_FUNCTION_NAME, I as invokeManifestTool, J as createRuntimeReportTools, K as RUNTIME_REPORT_SOURCES_FUNCTION_NAME, L as manifestToolToAITool, M as MAX_CLIENT_TOOL_SCHEMA_BYTES, N as appendClientToolResults, O as CLIENT_TOOL_RESULT_GUIDANCE, P as buildManifestToolCatalog, R as runToolLoop, S as DEFAULT_CONTINUATION_TTL_MS, T as createMemoryContinuationStore, U as RUNTIME_REPORT_LIST_TOOL_SLUG, V as RUNTIME_REPORT_DEFINE_TOOL_SLUG, W as RUNTIME_REPORT_RUN_FUNCTION_NAME, _ as runPersonaConversationTurn, a as MAX_CHAT_STREAM_CONTENT_LENGTH, b as CONTINUATION_CLAIM_TTL_MS, c as encodeChatStreamEvent, d as conversationPersonaFromAgentPersona, f as conversationPersonaFromResolved, g as resolveConversationInstructions, h as recallPersonaMemory, i as DEFAULT_CHAT_STREAM_HEARTBEAT_MS, j as MAX_CLIENT_TOOL_RESULT_CHARS, k as DEFAULT_MAX_STEPS, l as runChatConversationStream, m as principalBindingFor, n as ChatStreamError, o as MAX_CHAT_STREAM_MESSAGES, p as formatRecalledMemory, q as RUNTIME_REPORT_SOURCES_TOOL_SLUG, r as ChatStreamUnauthorizedError, s as createChatStreamHandler, t as ChatStreamBadRequestError, u as bindPersonaToSession, v as ASSISTANT_TURN_GENERIC_ERROR, w as createAssistantTurnResponse, x as DEFAULT_ASSISTANT_TURN_HEARTBEAT_MS, y as AssistantTurnUserError, z as sanitizeClientToolDeclarations } from "./chunks/chat-stream-CXS-F9In.js";
2
+ import { A as MAX_CLIENT_TOOLS, B as RUNTIME_REPORT_DEFINE_FUNCTION_NAME, C as SESSION_CONTINUATIONS_FIELD, D as runAssistantTurn, E as createSessionContinuationStore, F as classifyToolError, G as RUNTIME_REPORT_RUN_TOOL_SLUG, H as RUNTIME_REPORT_LIST_FUNCTION_NAME, I as invokeManifestTool, J as createRuntimeReportTools, K as RUNTIME_REPORT_SOURCES_FUNCTION_NAME, L as manifestToolToAITool, M as MAX_CLIENT_TOOL_SCHEMA_BYTES, N as appendClientToolResults, O as CLIENT_TOOL_RESULT_GUIDANCE, P as buildManifestToolCatalog, R as runToolLoop, S as DEFAULT_CONTINUATION_TTL_MS, T as createMemoryContinuationStore, U as RUNTIME_REPORT_LIST_TOOL_SLUG, V as RUNTIME_REPORT_DEFINE_TOOL_SLUG, W as RUNTIME_REPORT_RUN_FUNCTION_NAME, _ as runPersonaConversationTurn, a as MAX_CHAT_STREAM_CONTENT_LENGTH, b as CONTINUATION_CLAIM_TTL_MS, c as encodeChatStreamEvent, d as conversationPersonaFromAgentPersona, f as conversationPersonaFromResolved, g as resolveConversationInstructions, h as recallPersonaMemory, i as DEFAULT_CHAT_STREAM_HEARTBEAT_MS, j as MAX_CLIENT_TOOL_RESULT_CHARS, k as DEFAULT_MAX_STEPS, l as runChatConversationStream, m as principalBindingFor, n as ChatStreamError, o as MAX_CHAT_STREAM_MESSAGES, p as formatRecalledMemory, q as RUNTIME_REPORT_SOURCES_TOOL_SLUG, r as ChatStreamUnauthorizedError, s as createChatStreamHandler, t as ChatStreamBadRequestError, u as bindPersonaToSession, v as ASSISTANT_TURN_GENERIC_ERROR, w as createAssistantTurnResponse, x as DEFAULT_ASSISTANT_TURN_HEARTBEAT_MS, y as AssistantTurnUserError, z as sanitizeClientToolDeclarations } from "./chunks/chat-stream-CfZelFch.js";
3
3
  import { i as matchesToolAllowList, n as encodeAssistantTurnEvent, r as readAssistantTurnStream, t as AssistantTurnStreamError } from "./chunks/assistant-turn-events-BbZUf-Ju.js";
4
4
  import { a as createServerDataSurfaceBridge, i as createDataSurfaceCommandBridge, n as DEFAULT_DATA_SURFACE_BRIDGE_TTL_MS, r as createDataSurfaceBridge, t as DATA_SURFACE_BRIDGE_VERSION } from "./chunks/data-surface-bridge-BJomMxjS.js";
5
5
  import { HELPER_CLEARED_PREFERENCES, HELPER_PREFERENCES_VERSION, helperPreferenceFields, isHelperClearedPreferences, parseHelperPreferences } from "./helper-preferences.js";
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-chat",
5
- "packageVersion": "0.57.5",
5
+ "packageVersion": "0.57.7",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-chat:AgentSessionCollection": {
8
8
  "name": "agentsessioncollection",
@@ -3,16 +3,17 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-chat",
6
- "packageVersion": "0.57.5",
6
+ "packageVersion": "0.57.7",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "5c551d9eef7b8f37d95f74f492207e6118623ba0bf1f9143b92404695a7ba644",
11
- "packageJson": "179c0658629544f373792b099c96383988c2ac0c3ecd5633522adc97507de66b",
12
- "agents": "74a05bfffe21c3f63bc4289112d25054b0351f411a9de8079fc871b66caad27c",
10
+ "manifest": "51044cf6eb058f833f04e8abac18056874bf20d0abc9712ddada4c0535682ad7",
11
+ "packageJson": "216617ca6afb3357f1badf48b0ca120cb302ebb966a29eca688f8f75c0917a3d",
12
+ "agents": "fbe59050d13081c743f92497371a2911d07e3b23720571f28fe07c1078c411f1",
13
13
  "moduleDoc:agents/dev-workbench.md": "f07a428519e8cca4a46371da0a3b33b9c8d3102f1d9056e4c8efba3c0b702048",
14
14
  "moduleDoc:agents/overview-tools.md": "752a4afb6b57a2e9224f3c65a98b0deb23cda87371f01ac0566f0887e9bb12da",
15
15
  "moduleDoc:agents/token-streaming.md": "e92a28a8702d111a3751a86b8cdff30cb5146cc115fed86c227402bef1c683fd",
16
+ "moduleDoc:agents/assistant-questions.md": "a0371870b3bc99e313d8287912208f3e315bab7dc4ee138214895031ae79a5b0",
16
17
  "moduleDoc:agents/recipes.md": "89c2c86e4e26cea9ba10d917f8ad71c23a71d4fd8eaacf638a6cd252e2376c07",
17
18
  "agentSurface:src/routes/chat-dev.intents.ts": "ff8f7c19c7eb6e841a08f03c97773c53092ed208004666bfed372bd1ad456b35"
18
19
  },
@@ -4512,7 +4513,7 @@
4512
4513
  "polymorphicAssociations": 0,
4513
4514
  "uuidColumns": 78
4514
4515
  },
4515
- "agentDoc": "# @happyvertical/smrt-chat\n\nChat rooms, threads, and agent sessions with app-controlled tool whitelisting.\n\n## Dev Server\n\n`pnpm --dir packages/chat dev` runs a package-local SvelteKit workbench. The root\nroute separates **Character setup** and **Chat dev** into tabs; switching tabs\nretains the current character rig and chat state. The chat tab is an interactive\nsurface with a dev-only `/api/dev-chat` endpoint:\nit uses `@happyvertical/ai` when local provider credentials are present and\nfalls back to a deterministic local assistant otherwise. `/api/dev-chat-stream`\nis its SSE companion (#1936) — the same provider/local-fallback resolution wired\nthrough `createChatStreamHandler` in plain mode, so an embedded `SmrtChatBackend`\nclient can exercise token streaming locally. `/previews` hosts the shared\ncomponent playground entries from `src/svelte/playground.ts`.\n\nThe root route also drives the **browser inference path**\n(`@happyvertical/smrt-web/ai`: bitgpu, WebLLM, and route backends composed by\n`createInferencePath`) and an **agent-addressable draft form** (`useViewIntent`\nover `src/routes/chat-dev.intents.ts`, #2588). Their dev-config dependencies\n(static optional-peer imports, `workspace-aliases.js` prefix rules, the theme\nstylesheet import) and the stage→apply tool-call gotchas are in\n[agents/dev-workbench.md](agents/dev-workbench.md).\n\nDev-only voice and opt-in local character persistence are documented in\n[agents/dev-workbench.md](agents/dev-workbench.md#voice-and-local-character-persistence).\nThese endpoints are loopback-only demos; production hosts supply authenticated\nprincipals, tenancy, and authorization to the owning services.\n\n## Models\n\nAll seven models expose only generated REST/MCP `list`/`get`; structural tests\ncheck the registry for accidental mutating operations. Writes go through\nmembership/owner-checked `ChatService` or the binding-checked voice adapter.\n\n`ChatService` is a closed facade. Its collections and security-sensitive\nhelpers use ES `#private` fields/methods (TypeScript `private` is erased).\nCollection classes are not exported from the package index. The agent-reply\nbridge is a Symbol-keyed static accessible through a non-exported symbol held\nby the module-local `sendAgentReply` function.\n\n- **ChatRoom**: `roomType` (public/private/dm/agent), `status`, `topic`, `maxParticipants`, `lastMessageAt`. Tenant-scoped (required).\n- **ChatMessage**: shared by users + agents. `role` (user/assistant/system/tool), `messageType` (text/system/action/file/tool_call/tool_result), `toolCallData` JSON. Unified model — no separate agent message type. Tenant-scoped (required).\n- **ChatParticipant**: `role` (owner/admin/member/viewer), `onlineStatus`, `lastReadMessageId`, `isMuted`. Tenant-scoped (required).\n- **ChatThread**: `rootMessageId`, `isResolved`, `messageCount`. Created via `ChatService.startThread()` (member-checked). Tenant-scoped (required).\n- **ChatReaction**: `messageId`, `profileId`, `emoji`. Added/removed via `ChatService.addReaction()`/`removeReaction()` (member-checked, self-keyed). Tenant-scoped (required).\n- **AgentSession**: `agentId` (application slug, string ref, not FK), `agentProfileId` (nullable `crossPackageRef` to `Profile` — the `bot` profile the agent AUTHORS as, #2995), `allowedTools` (JSON string array), `sessionContext` (JSON, sensitive), `systemPrompt`, limits (`maxTokens`/`maxMessages`/`expiresAt`). Optional tenancy.\n- **VoiceSession**: short-lived voice-gateway binding over `(tenant, actorProfileId, personaId, room/thread/agentSession)` plus a persona snapshot, gateway `session_id`, expiry, replay tracking, and metadata. Tenant-scoped (required). Generated surface is read-only; creation and turns go through `createVoiceChatSession()` / `handleVoiceGatewayTurn()`.\n\n## ChatService\n\nPublic writes take server-supplied `actorProfileId`; never trust a request's\n`senderProfileId`, `role`, owner id, or participant id as authority.\n\n| Operation | Authorization and behavior |\n|---|---|\n| `sendMessage()` | Room member; authors as actor with `role: 'user'`; no public membership bypass |\n| `createRoom()` | Actor becomes owner |\n| `startThread()` | Member; optional root message bound to room and tenant |\n| `addParticipant()` / `removeParticipant()` | Owner/admin; self-leave allowed |\n| `updateRoom()` | Owner/admin |\n| `addReaction()` / `removeReaction()` | Member; self-keyed |\n| `getOrCreateDM()` | Actor must be a DM participant |\n| `createAgentSession()` | Actor becomes session participant; existing-session lookup tenant-bound; optional `sessionKey` isolates conversation subjects |\n| `updateAgentSessionConfig()` | Owner; mandatory tenant-bound lookup |\n| `sendAgentUserMessage()` | Actor must be session participant; authored as participant |\n\nRead facade: `getAgentSession({agentSessionId, tenantId})`,\n`findActiveAgentSessions({tenantId, agentId, participantProfileId})`, and\n`getThread({threadId, tenantId})` are tenant-bound; consumers still check\nownership/context. `listRoomThreads({roomId, actorProfileId, tenantId})`,\n`getThreadMessages({threadId, actorProfileId, tenantId, limit?})` (chronological),\n`getRoomMessages({roomId, actorProfileId, tenantId})`, and\n`getRoomForMember(roomId, actorProfileId, tenantId)` additionally require\nmembership of the server-supplied actor, not a caller-selected subject.\n\nTrusted agent runtime imports `sendAgentReply(service, params)` only from\n`@happyvertical/smrt-chat/internal/agent-runtime`. It is not a `ChatService`\nmethod or package-index export. It authors as `session.agentProfileId` — the\nagent's resolved `bot` Profile, NEVER the `agentId` slug (#2995) — accepts a\nsame-room/tenant thread, and enforces `allowedTools` fail-closed.\n\nPrivate `#writeMessage` alone can select arbitrary authors/roles or bypass\nmembership. It binds every supplied `threadId`, `agentSessionId`, and\n`replyToMessageId` to the same room **and** tenant. Internal enrollment supports\nautomatically created rooms, sessions, and participants.\n\n## Agent Tool Whitelisting\n\n`allowedTools` is a JSON array controlled by the consuming app. Fail-closed: an empty/unparseable whitelist permits NO tools. The internal `sendAgentReply(service, params)` function enforces the whitelist before emitting any `tool`/`tool_call` message; a caller cannot supply a `senderProfileId`/`role` to post as the agent, and the function is not reachable from the package index.\n\nPrincipal-bound data discovery and reads are available as `PrincipalTool[]` via\n`createDataSurfaceTools()` (re-exported by the chat package). Pass the tools as\n`extraTools` to the persona conversation/tool loop. They use the same\nfail-closed `allowedTools` offer/execution gates; RBAC, tenant, redaction,\nbounded query results, and audit authority remain in the authenticated\n`PrincipalRun` supplied by `@happyvertical/smrt-agents`.\n\n## Commerce money tools (#3937)\n\nHosts that offer invoice money work must explicitly register\n`createCommerceMoneyTools()` from `@happyvertical/smrt-commerce` in\ntheir authenticated assistant route's `extraTools` (for example, `extraTools: () => createCommerceMoneyTools()`) and\nadd only the desired `commerce.*` slugs to the persona's `allowedTools`.\nThey are not generic manifest CRUD tools: tax codes are selected by code,\npreview is calculator-only, and line writes run through the document-money\ncoordinator. Each execution reasserts the principal allow-list and required\n`Invoice`, `InvoiceLineItem`, `DocumentTax`, and `TaxCode` operations. The\ngenerated MCP invoice `get` serializer remains commerce-owned; use its\nstored-facts integration when that serializer exposes `DocumentTax` rows.\n\n## Runtime report tools (#3711)\n\n`createRuntimeReportTools()` (`runtime-report-tools.ts`) returns four\n`PrincipalTool`s for the persona `extraTools` seam: `reports.runtime.sources`,\n`reports.runtime.define` (`preview` / `apply`), `reports.runtime.list`, and\n`reports.runtime.run`. The model supplies only a declarative\n`RuntimeReportSpec` from `@happyvertical/smrt-reports`; sources are a\nserver-owned allow-list gated by `run.assertOperation(collection, 'read')`, and\nfield policy, `readPermission`, and tenancy are enforced by the compiler against\nthe live run on every compile and every stored-report run. `apply` needs the\n`specHash` of the previewed spec plus an app-owned `confirmation` host that\nresolves only after a human approved that exact spec; with no host apply returns an `awaiting_confirmation` proposal for the\nsame-origin browser confirm route and stores nothing and a model-supplied `confirmed` flag is ignored. Saving also needs\n`create` on the `RuntimeReport` collection, and `list`/`run` need `read`; the\ndefault `reportsCollection` is the `RuntimeReport` class itself, which resolves\nto the catalog slug. A string `collection`/`reportsCollection` is used verbatim\nas a catalog slug (never a class name); tests must run the tools under the real\n`assertOperationPermission` guard so a wrong slug fails. A stored report is\nwritten only by this confirmed `apply`: the model layer refuses every other\ninsert/spec change, and the generic manifest tools (`buildManifestToolCatalog`,\n`invokeManifestTool`) never offer or run a `RuntimeReport` write. Stored-report\nreads/saves run under the principal's tenant (`withPrincipalTenant`), refuse a\nmismatching ambient tenant (checked before `confirmSave`), and filter by the principal tenant explicitly. Contract and authority model:\n[`packages/reports/agents/runtime-reports.md`](../reports/agents/runtime-reports.md).\n\n## Overview tools (#3727)\n\n`createOverviewTools({ host })` (`overview-tools.ts`) returns\n`overviews.describe`, `overviews.apply` (an atomic batch of structured widget\noperations) and `overviews.undo` (single step, keyed by tenant, user and page).\nThe host opens a page per `PrincipalRun` (smrt-svelte's\n`createOverviewAssistantSurface`, typed structurally here); validation and\n`canCustomize` are the surface's. Contract:\n[agents/overview-tools.md](agents/overview-tools.md).\n\n## Conversational Harness (L3, #1891)\n\nThe `AgentSession` runtime depends on personas, agents, and users. Keep those\nedges acyclic: none of those packages may depend back on chat.\n\n- **`runToolLoop(options)`** (`tool-loop.ts`) — a bounded `tool_call → observe → respond` loop. Tools are **manifest operations** of installed packages: `buildManifestToolCatalog({ allowedTools })` reads the `PermissionCatalogService` catalog and keeps only the `(collection, action)` entries named in the persona's allow-list (the **offer gate**; absent/empty ⇒ NO tools). The loop runs inside one `executeAsPrincipal` context, and `invokeManifestTool` executes each op **in-process (\"side door\")** against `run.context.database` (the RLS tx when Postgres RLS is on), after re-asserting the fail-closed allow-list (`run.assertToolAllowed`) AND the catalog permission (`run.assertOperation`) — the **execution gate**. Bounded by a max-steps ceiling (`DEFAULT_MAX_STEPS = 8`): on the ceiling it disables tools for one final completion so the turn always terminates with text. A thrown tool error is classified (`classifyToolError`): permission denials and 401/403 are `not_permitted`; a `ValidationError` or 400/404/409/422 is `invalid_request` (observation: actionable message, `publicMessage` first, `code`, fix-and-retry hint); else (incl. 429) `execution_error`.\n- **`runPersonaConversationTurn(options)`** (`persona-conversation.ts`) — binds a conversation to an `AgentPersona`/`ResolvedPersona`: runs as its principal (`runAsUserId`), offers only its `allowedTools`, speaks its instructions (`resolvePersonaInstructions`, layering approved learned directives), and injects its **recalled learning memory** (`personaLearningMemory`, isolated per `memoryScope`) into the system prompt. `bindPersonaToSession()` mirrors the persona's `allowedTools`/instructions onto the `AgentSession` so the chat authoring gate agrees with the loop's offer gate. Authors the reply (and each executed tool) via the internal `sendAgentReply` bridge. Tools run with the run-as user's **live** permissions unless the host passes a `permissions` snapshot (#2978), which `runToolLoop` forwards to `executeAsPrincipal` so a narrowed principal (e.g. a shared station) cannot reach grants — such as `readPermission`-gated data-surface fields — beyond that snapshot, including through `agents.invoke` delegation (the snapshot becomes the worker's permission ceiling).\n- **Agent orchestration** (L3, #1892) — the loop accepts non-manifest **`extraTools`** (`PrincipalTool[]`, from `@happyvertical/smrt-agents`), gated by the *same* fail-closed allow-list. The standard **`invoke-agent`** tool (`createInvokeAgentTool`, slug `agents.invoke`) lets a conversational agent delegate to a **worker agent under its own principal** — the worker runs via `executeAsPrincipal` as the originating user (never its own authority), the principal is immutable along the chain, and its completion is surfaced back into the conversation. `runPersonaConversationTurn` filters `extraTools` by the persona's `allowedTools` (offer gate); the tool's `execute` re-asserts `assertToolAllowed` (execution gate). See `@happyvertical/smrt-agents` for the delegation envelope + transports.\n- **Chat feedback capture** (`chat-feedback.ts`) — `captureChatFeedback()` + `acceptAppliedChange`/`rejectAppliedChange`/`correctResponse`/`rateResponse`/`thumbsUp`/`thumbsDown` write a `Feedback` row (personas) carrying the conversation's **correlation-id**, and (by default) reinforce the persona's learning memory (`reinforceFromFeedback`). So an in-chat reject decays a strategy below the reuse floor and it stops being recalled; a correction supersedes its stored value.\n\n## Voice Gateway Turns (#1910)\n\nVoice is an input mode for the existing persona chat harness, not a separate chat runtime. `createVoiceChatSession()` creates a short-lived `VoiceSession` for an authenticated actor/profile, binding tenant, persona, agent session, room, and optional thread. `handleVoiceGatewayTurn()` resolves that binding from `metadata.voiceSessionId`, checks the gateway's `session_id` and any supplied tenant/profile/persona/session/thread metadata against the server-side binding, persists the transcript through `ChatService.sendAgentUserMessage()`, runs `runPersonaConversationTurn()`, stamps voice/correlation metadata onto the persisted user/assistant/tool messages, records the gateway `turn_id`, and returns the gateway response contract. The Fetch-compatible `createVoiceGatewayTurnHandler()` adds the coarse gateway bearer-token check.\n\nThe gateway bearer token proves only \"this request came from the gateway\"; it never authorizes the end user. The short-lived `VoiceSession` binding is the user/session proof, and untrusted gateway metadata must be validated against that binding before any chat write or tool loop. Tool execution remains fail-closed through the persona allow-list mirrored onto `AgentSession` by `bindPersonaToSession()`.\n\n## Token streaming\n\n`chat-stream.ts` serves browser-safe SSE through `./client`. The host's\n`authorize(request, body)` is the sole trust boundary: resolve identity, tenant,\ncapabilities, and generation limits server-side; never authorize from request\nsession metadata. Persona turns reuse the principal and fail-closed tool gates.\nThe engine and browser-safe `SmrtChatBackend` keep custom-tool resolution\nserver-side. See [agents/token-streaming.md](agents/token-streaming.md) for the\nevent contract, CORS policy, and streaming behavior.\n\n## Streamed assistant turns with browser tools (#2908)\n\n`runAssistantTurn()` (`assistant-turn.ts`) is the host-route engine for the\nAssistantDock: one turn over `runToolLoop` with server tools (offer-gated by\n`principal.allowedTools`), browser tools (validated by\n`sanitizeClientToolDeclarations` + a server allow-list), `maxSteps`, `signal`,\nand `onUsage`. A browser tool call SUSPENDS the turn (`stoppedReason:\n'client_tools'`); the transcript waits in an `AssistantContinuationStore`\n(single-use, 15-minute TTL; `createSessionContinuationStore` keeps it in the\nAgentSession's `sessionContext`) and the browser resumes it with results,\nwhich reach the model wrapped `untrusted`. `createAssistantTurnResponse`\nserves the events as SSE; `assistant-turn-events.ts` is the browser-safe wire\ncontract (`./assistant-turn` subpath, also a vite library entry because the\nSvelte dock imports it). The dock decides whether a browser call runs or waits\nfor the user from the page registry, never the server's echo; destructive\nalways waits. Details: [`docs/assistant-dock.md`](../../docs/assistant-dock.md).\n\n## AssistantDock (#2904)\n\n`AssistantDock` (`svelte/components/assistant/`, exported from `./svelte`)\nuses a host `DataSurfaceRegistry`'s mounted descriptors (none: plain chat).\nActions: `normalizeDataSurfaceActionRequest` → `AssistantActionClient`\n(preview/apply over a server `DataSurfaceActionAdapter`, not the\n`data-surface-bridge.ts` channel). Generated `ChatThread`/`ChatMessage`\n`list`/`get` are tenant- not member-scoped; the dock never calls them.\n\n**Server routes (#3368).** `./sveltekit` `mountAssistantRoutes()` serves the\ndock from one `[...path]` route; `createAssistantHttp{Transport,ActionClient}`\n(`./svelte`) are its browser half. Principal from `event.locals` or\n`resolvePrincipal`; threads only from the actor's keyed session room in that\ntenant (else 404). `allowedTools` is fail-closed; without `tools` it offers\nthose manifest ops (an unprovided name is an error, #3414). `ai` defaults to\nthe config `ai` block. Pass `runtime`: a turn never keeps the RLS request tx\n(own `runAsPrincipal` tx). `clientRequestId` makes the row id a UUIDv5\n(`clientRequestMessageId`): the PK is the retry reservation. See\n[docs](../../docs/assistant-dock.md).\n\n## Recipes (#3719)\n\n`src/recipes.ts` declares `chat.assistant` (the AssistantDock as a `header.end`\nshell widget, an `llm` provider, runtime `both`; help in\n`src/assistant.recipe.md`). Keep each recipe class self-contained. Details:\n[agents/recipes.md](agents/recipes.md).\n\n## Gotchas\n\n- **sessionContext, not context**: `context` is reserved for slug scoping. Use `getSessionContext()`/`updateSessionContext()` for agent memory.\n- **Agent rooms auto-created**: `roomType: 'agent'`, `maxParticipants` defaults to 2; the agent is enrolled as a member so its replies pass the membership check. `createAgentSession()` re-enrolls the participant AND the agent on the existing-session path, so legacy sessions created before the agent was enrolled self-heal.\n- **The agent's author is a Profile, not `agentId` (#2995)**: `senderProfileId`/`profileId` are `crossPackageRef`s to `Profile` and therefore uuid columns on PostgreSQL, so the slug fails the cast (`22P02`) and no agent reply can persist. `createAgentSession()` resolves the agent's `bot` Profile through `resolveAgentProfile()` in `@happyvertical/smrt-profiles` (created on first use, tenant-bound) and records it as `AgentSession.agentProfileId`; both enrolment and `sendAgentReply` use that uuid. A consumer whose agent already has an acting identity passes `agentProfileId` to skip resolution — the voice path does this with the persona's `actsAsProfileId`, so persona replies stay attributed to the configured acting profile. Like `actorProfileId`, `agentProfileId` is SERVER-supplied, never request input: it selects the author of every later assistant message. It is validated (`#requireAgentProfile`) to exist and be visible in the tenant and to differ from the acting participant, and it can never re-point a session that already resolved one — only a pre-#2995 session with none is backfilled. **Migration**: `agent_profile_id` is an ordinary additive manifest field, so a consumer adopts it the same way as any other — `smrt db:migrate` generates and applies the column from the manifest, and `db:status --parity` verifies it; the framework never creates application schema at runtime. It is nullable and needs no offline DATA backfill — a session created before #2995 resolves and persists its `agentProfileId`, and re-enrols the agent, on its first agent turn after the upgrade. Two pre-#2995 cohorts: a session whose `agentId` is an ordinary slug never committed an author on PostgreSQL (the cast failed), so there is nothing to repair there, while SQLite/DuckDB may hold `chat_messages`/`chat_participants` rows whose id column holds the slug — readable, but not joinable to `profiles`. A session whose `agentId` is already a Profile uuid (the voice path used to collapse a persona's `actsAsProfileId` into it) DID commit on PostgreSQL; that profile is ADOPTED as the author, tenant-checked and never the participant, so the conversation's author does not change and the two-seat room gains no third identity. Known one-time effect: because `voice.ts` no longer derives `agentId` from `actsAsProfileId`, a keyless `createVoiceChatSession()` for such a persona opens a new session and room instead of reusing the legacy one; the old room and its messages stay intact and readable, and resuming with an explicit `agentSessionId` is unaffected.\n- **Only SQLite-backed tests would miss this class of bug**: uuid columns accept any text on SQLite. `src/__tests__/agent-reply-postgres.test.ts` (`pnpm --filter @happyvertical/smrt-chat test:postgres`) pins the agent-reply path against real PostgreSQL. That suite runs in `postgres-tests.yml` on every same-repository PR and merge group (#2659); keep a cheap SQLite assertion alongside it anyway, since the default lane is SQLite-only (`chat-service.test.ts` asserts the author is the profile uuid, not the slug).\n- **Per-subject sessions need `sessionKey`**: `createAgentSession()` reuses ANY active session for the same `(agentId, participantProfileId, tenantId)`. Callers that open separate conversations per subject (e.g. one content-editor session per content id) MUST pass a stable `sessionKey` (stored in `sessionContext.__sessionKey`, read via `AgentSession.getSessionKey()`); otherwise a session opened for one subject is reused and its context overwritten for another, surfacing the wrong room/threads (S5 #1392). A keyed create never reuses a keyless/legacy session.\n- **Session expiry**: check `isActive()` before allowing messages (expiresAt or limit-based)\n- **DM identity**: derived from the deterministic per-tenant `canonicalDmRoomId()` and the authoritative `chat_participants` join, not client metadata; concurrent creates upsert onto one row.\n- **Tenant-bound lookups**: membership/session/DM lookups REQUIRE `tenantId` and always bind it into the WHERE clause (`findActiveMembership`/`isActiveMember`/`findActiveSession` take a required `tenantId`; AgentSession's `null` tenant is an explicit bound scope, not \"any tenant\") so they can never resolve a row from another tenant.\n",
4516
+ "agentDoc": "# @happyvertical/smrt-chat\n\nChat rooms, threads, and agent sessions with app-controlled tool whitelisting.\n\n## Dev Server\n\n`pnpm --dir packages/chat dev` runs a package-local SvelteKit workbench. The root\nroute separates **Character setup** and **Chat dev** into tabs; switching tabs\nretains the current character rig and chat state. The chat tab is an interactive\nsurface with a dev-only `/api/dev-chat` endpoint:\nit uses `@happyvertical/ai` when local provider credentials are present and\nfalls back to a deterministic local assistant otherwise. `/api/dev-chat-stream`\nis its SSE companion (#1936) — the same provider/local-fallback resolution wired\nthrough `createChatStreamHandler` in plain mode, so an embedded `SmrtChatBackend`\nclient can exercise token streaming locally. `/previews` hosts the shared\ncomponent playground entries from `src/svelte/playground.ts`.\n\nThe root route also drives the **browser inference path**\n(`@happyvertical/smrt-web/ai`: bitgpu, WebLLM, and route backends composed by\n`createInferencePath`) and an **agent-addressable draft form** (`useViewIntent`\nover `src/routes/chat-dev.intents.ts`, #2588). Their dev-config dependencies\n(static optional-peer imports, `workspace-aliases.js` prefix rules, the theme\nstylesheet import) and the stage→apply tool-call gotchas are in\n[agents/dev-workbench.md](agents/dev-workbench.md).\n\nDev-only voice and opt-in local character persistence are documented in\n[agents/dev-workbench.md](agents/dev-workbench.md#voice-and-local-character-persistence).\nThese endpoints are loopback-only demos; production hosts supply authenticated\nprincipals, tenancy, and authorization to the owning services.\n\n## Models\n\nAll seven models expose only generated REST/MCP `list`/`get`; structural tests\ncheck the registry for accidental mutating operations. Writes go through\nmembership/owner-checked `ChatService` or the binding-checked voice adapter.\n\n`ChatService` is a closed facade. Its collections and security-sensitive\nhelpers use ES `#private` fields/methods (TypeScript `private` is erased).\nCollection classes are not exported from the package index. The agent-reply\nbridge is a Symbol-keyed static accessible through a non-exported symbol held\nby the module-local `sendAgentReply` function.\n\n- **ChatRoom**: `roomType` (public/private/dm/agent), `status`, `topic`, `maxParticipants`, `lastMessageAt`. Tenant-scoped (required).\n- **ChatMessage**: shared by users + agents. `role` (user/assistant/system/tool), `messageType` (text/system/action/file/tool_call/tool_result), `toolCallData` JSON. Unified model — no separate agent message type. Tenant-scoped (required).\n- **ChatParticipant**: `role` (owner/admin/member/viewer), `onlineStatus`, `lastReadMessageId`, `isMuted`. Tenant-scoped (required).\n- **ChatThread**: `rootMessageId`, `isResolved`, `messageCount`. Created via `ChatService.startThread()` (member-checked). Tenant-scoped (required).\n- **ChatReaction**: `messageId`, `profileId`, `emoji`. Added/removed via `ChatService.addReaction()`/`removeReaction()` (member-checked, self-keyed). Tenant-scoped (required).\n- **AgentSession**: `agentId` (application slug, string ref, not FK), `agentProfileId` (nullable `crossPackageRef` to `Profile` — the `bot` profile the agent AUTHORS as, #2995), `allowedTools` (JSON string array), `sessionContext` (JSON, sensitive), `systemPrompt`, limits (`maxTokens`/`maxMessages`/`expiresAt`). Optional tenancy.\n- **VoiceSession**: short-lived voice-gateway binding over `(tenant, actorProfileId, personaId, room/thread/agentSession)` plus a persona snapshot, gateway `session_id`, expiry, replay tracking, and metadata. Tenant-scoped (required). Generated surface is read-only; creation and turns go through `createVoiceChatSession()` / `handleVoiceGatewayTurn()`.\n\n## ChatService\n\nPublic writes take server-supplied `actorProfileId`; never trust a request's\n`senderProfileId`, `role`, owner id, or participant id as authority.\n\n| Operation | Authorization and behavior |\n|---|---|\n| `sendMessage()` | Room member; authors as actor with `role: 'user'`; no public membership bypass |\n| `createRoom()` | Actor becomes owner |\n| `startThread()` | Member; optional root message bound to room and tenant |\n| `addParticipant()` / `removeParticipant()` | Owner/admin; self-leave allowed |\n| `updateRoom()` | Owner/admin |\n| `addReaction()` / `removeReaction()` | Member; self-keyed |\n| `getOrCreateDM()` | Actor must be a DM participant |\n| `createAgentSession()` | Actor becomes session participant; existing-session lookup tenant-bound; optional `sessionKey` isolates conversation subjects |\n| `updateAgentSessionConfig()` | Owner; mandatory tenant-bound lookup |\n| `sendAgentUserMessage()` | Actor must be session participant; authored as participant |\n\nRead facade: `getAgentSession({agentSessionId, tenantId})`,\n`findActiveAgentSessions({tenantId, agentId, participantProfileId})`, and\n`getThread({threadId, tenantId})` are tenant-bound; consumers still check\nownership/context. `listRoomThreads({roomId, actorProfileId, tenantId})`,\n`getThreadMessages({threadId, actorProfileId, tenantId, limit?})` (chronological),\n`getRoomMessages({roomId, actorProfileId, tenantId})`, and\n`getRoomForMember(roomId, actorProfileId, tenantId)` additionally require\nmembership of the server-supplied actor, not a caller-selected subject.\n\nTrusted agent runtime imports `sendAgentReply(service, params)` only from\n`@happyvertical/smrt-chat/internal/agent-runtime`. It is not a `ChatService`\nmethod or package-index export. It authors as `session.agentProfileId` — the\nagent's resolved `bot` Profile, NEVER the `agentId` slug (#2995) — accepts a\nsame-room/tenant thread, and enforces `allowedTools` fail-closed.\n\nPrivate `#writeMessage` alone can select arbitrary authors/roles or bypass\nmembership. It binds every supplied `threadId`, `agentSessionId`, and\n`replyToMessageId` to the same room **and** tenant. Internal enrollment supports\nautomatically created rooms, sessions, and participants.\n\n## Agent Tool Whitelisting\n\n`allowedTools` is a JSON array controlled by the consuming app. Fail-closed: an empty/unparseable whitelist permits NO tools. The internal `sendAgentReply(service, params)` function enforces the whitelist before emitting any `tool`/`tool_call` message; a caller cannot supply a `senderProfileId`/`role` to post as the agent, and the function is not reachable from the package index.\n\nPrincipal-bound data discovery and reads are available as `PrincipalTool[]` via\n`createDataSurfaceTools()` (re-exported by the chat package). Pass the tools as\n`extraTools` to the persona conversation/tool loop. They use the same\nfail-closed `allowedTools` offer/execution gates; RBAC, tenant, redaction,\nbounded query results, and audit authority remain in the authenticated\n`PrincipalRun` supplied by `@happyvertical/smrt-agents`.\n\n## Commerce money tools (#3937)\n\nHosts that offer invoice money work must explicitly register\n`createCommerceMoneyTools()` from `@happyvertical/smrt-commerce` in\ntheir authenticated assistant route's `extraTools` (for example, `extraTools: () => createCommerceMoneyTools()`) and\nadd only the desired `commerce.*` slugs to the persona's `allowedTools`.\nThey are not generic manifest CRUD tools: tax codes are selected by code,\npreview is calculator-only, and line writes run through the document-money\ncoordinator. Each execution reasserts the principal allow-list and required\n`Invoice`, `InvoiceLineItem`, `DocumentTax`, and `TaxCode` operations. The\ngenerated MCP invoice `get` serializer remains commerce-owned; use its\nstored-facts integration when that serializer exposes `DocumentTax` rows.\n\n## Runtime report tools (#3711)\n\n`createRuntimeReportTools()` (`runtime-report-tools.ts`) returns four\n`PrincipalTool`s for the persona `extraTools` seam: `reports.runtime.sources`,\n`reports.runtime.define` (`preview` / `apply`), `reports.runtime.list`, and\n`reports.runtime.run`. The model supplies only a declarative\n`RuntimeReportSpec` from `@happyvertical/smrt-reports`; sources are a\nserver-owned allow-list gated by `run.assertOperation(collection, 'read')`, and\nfield policy, `readPermission`, and tenancy are enforced by the compiler against\nthe live run on every compile and every stored-report run. `apply` needs the\n`specHash` of the previewed spec plus an app-owned `confirmation` host that\nresolves only after a human approved that exact spec; with no host apply returns an `awaiting_confirmation` proposal for the\nsame-origin browser confirm route and stores nothing and a model-supplied `confirmed` flag is ignored. Saving also needs\n`create` on the `RuntimeReport` collection, and `list`/`run` need `read`; the\ndefault `reportsCollection` is the `RuntimeReport` class itself, which resolves\nto the catalog slug. A string `collection`/`reportsCollection` is used verbatim\nas a catalog slug (never a class name); tests must run the tools under the real\n`assertOperationPermission` guard so a wrong slug fails. A stored report is\nwritten only by this confirmed `apply`: the model layer refuses every other\ninsert/spec change, and the generic manifest tools (`buildManifestToolCatalog`,\n`invokeManifestTool`) never offer or run a `RuntimeReport` write. Stored-report\nreads/saves run under the principal's tenant (`withPrincipalTenant`), refuse a\nmismatching ambient tenant (checked before `confirmSave`), and filter by the principal tenant explicitly. Contract and authority model:\n[`packages/reports/agents/runtime-reports.md`](../reports/agents/runtime-reports.md).\n\n## Overview tools (#3727)\n\n`createOverviewTools({ host })` (`overview-tools.ts`) returns\n`overviews.describe`, `overviews.apply` (an atomic batch of structured widget\noperations) and `overviews.undo` (single step, keyed by tenant, user and page).\nThe host opens a page per `PrincipalRun` (smrt-svelte's\n`createOverviewAssistantSurface`, typed structurally here); validation and\n`canCustomize` are the surface's. Contract:\n[agents/overview-tools.md](agents/overview-tools.md).\n\n## Conversational Harness (L3, #1891)\n\nThe `AgentSession` runtime depends on personas, agents, and users. Keep those\nedges acyclic: none of those packages may depend back on chat.\n\n- **`runToolLoop(options)`** (`tool-loop.ts`) — a bounded `tool_call → observe → respond` loop. Tools are **manifest operations** of installed packages: `buildManifestToolCatalog({ allowedTools })` reads the `PermissionCatalogService` catalog and keeps only the `(collection, action)` entries named in the persona's allow-list (the **offer gate**; absent/empty ⇒ NO tools). The loop runs inside one `executeAsPrincipal` context, and `invokeManifestTool` executes each op **in-process (\"side door\")** against `run.context.database` (the RLS tx when Postgres RLS is on), after re-asserting the fail-closed allow-list (`run.assertToolAllowed`) AND the catalog permission (`run.assertOperation`) — the **execution gate**. Bounded by a max-steps ceiling (`DEFAULT_MAX_STEPS = 8`): on the ceiling it disables tools for one final completion so the turn always terminates with text. A thrown tool error is classified (`classifyToolError`): permission denials and 401/403 are `not_permitted`; a `ValidationError` or 400/404/409/422 is `invalid_request` (observation: actionable message, `publicMessage` first, `code`, fix-and-retry hint); else (incl. 429) `execution_error`.\n- **`runPersonaConversationTurn(options)`** (`persona-conversation.ts`) — binds a conversation to an `AgentPersona`/`ResolvedPersona`: runs as its principal (`runAsUserId`), offers only its `allowedTools`, speaks its instructions (`resolvePersonaInstructions`, layering approved learned directives), and injects its **recalled learning memory** (`personaLearningMemory`, isolated per `memoryScope`) into the system prompt. `bindPersonaToSession()` mirrors the persona's `allowedTools`/instructions onto the `AgentSession` so the chat authoring gate agrees with the loop's offer gate. Authors the reply (and each executed tool) via the internal `sendAgentReply` bridge. Tools run with the run-as user's **live** permissions unless the host passes a `permissions` snapshot (#2978), which `runToolLoop` forwards to `executeAsPrincipal` so a narrowed principal (e.g. a shared station) cannot reach grants — such as `readPermission`-gated data-surface fields — beyond that snapshot, including through `agents.invoke` delegation (the snapshot becomes the worker's permission ceiling).\n- **Agent orchestration** (L3, #1892) — the loop accepts non-manifest **`extraTools`** (`PrincipalTool[]`, from `@happyvertical/smrt-agents`), gated by the *same* fail-closed allow-list. The standard **`invoke-agent`** tool (`createInvokeAgentTool`, slug `agents.invoke`) lets a conversational agent delegate to a **worker agent under its own principal** — the worker runs via `executeAsPrincipal` as the originating user (never its own authority), the principal is immutable along the chain, and its completion is surfaced back into the conversation. `runPersonaConversationTurn` filters `extraTools` by the persona's `allowedTools` (offer gate); the tool's `execute` re-asserts `assertToolAllowed` (execution gate). See `@happyvertical/smrt-agents` for the delegation envelope + transports.\n- **Chat feedback capture** (`chat-feedback.ts`) — `captureChatFeedback()` + `acceptAppliedChange`/`rejectAppliedChange`/`correctResponse`/`rateResponse`/`thumbsUp`/`thumbsDown` write a `Feedback` row (personas) carrying the conversation's **correlation-id**, and (by default) reinforce the persona's learning memory (`reinforceFromFeedback`). So an in-chat reject decays a strategy below the reuse floor and it stops being recalled; a correction supersedes its stored value.\n\n## Voice Gateway Turns (#1910)\n\nVoice is an input mode for the existing persona chat harness, not a separate chat runtime. `createVoiceChatSession()` creates a short-lived `VoiceSession` for an authenticated actor/profile, binding tenant, persona, agent session, room, and optional thread. `handleVoiceGatewayTurn()` resolves that binding from `metadata.voiceSessionId`, checks the gateway's `session_id` and any supplied tenant/profile/persona/session/thread metadata against the server-side binding, persists the transcript through `ChatService.sendAgentUserMessage()`, runs `runPersonaConversationTurn()`, stamps voice/correlation metadata onto the persisted user/assistant/tool messages, records the gateway `turn_id`, and returns the gateway response contract. The Fetch-compatible `createVoiceGatewayTurnHandler()` adds the coarse gateway bearer-token check.\n\nThe gateway bearer token proves only \"this request came from the gateway\"; it never authorizes the end user. The short-lived `VoiceSession` binding is the user/session proof, and untrusted gateway metadata must be validated against that binding before any chat write or tool loop. Tool execution remains fail-closed through the persona allow-list mirrored onto `AgentSession` by `bindPersonaToSession()`.\n\n## Token streaming\n\n`chat-stream.ts` serves browser-safe SSE through `./client`. The host's\n`authorize(request, body)` is the sole trust boundary: resolve identity, tenant,\ncapabilities, and generation limits server-side; never authorize from request\nsession metadata. Persona turns reuse the principal and fail-closed tool gates.\nThe engine and browser-safe `SmrtChatBackend` keep custom-tool resolution\nserver-side. See [agents/token-streaming.md](agents/token-streaming.md) for the\nevent contract, CORS policy, and streaming behavior.\n\n## Streamed assistant turns with browser tools (#2908)\n\n`runAssistantTurn()` (`assistant-turn.ts`) is the host-route engine for the\nAssistantDock: one turn over `runToolLoop` with server tools (offer-gated by\n`principal.allowedTools`), browser tools (validated by\n`sanitizeClientToolDeclarations` + a server allow-list), `maxSteps`, `signal`,\nand `onUsage`. A browser tool call SUSPENDS the turn (`stoppedReason:\n'client_tools'`); the transcript waits in an `AssistantContinuationStore`\n(single-use, 15-minute TTL; `createSessionContinuationStore` keeps it in the\nAgentSession's `sessionContext`) and the browser resumes it with results,\nwhich reach the model wrapped `untrusted`. `createAssistantTurnResponse`\nserves the events as SSE; `assistant-turn-events.ts` is the browser-safe wire\ncontract (`./assistant-turn` subpath, also a vite library entry because the\nSvelte dock imports it). The dock decides whether a browser call runs or waits\nfor the user from the page registry, never the server's echo; destructive\nalways waits. Details: [`docs/assistant-dock.md`](../../docs/assistant-dock.md).\n\n## AssistantDock (#2904)\n\n`AssistantDock` (`svelte/components/assistant/`, exported from `./svelte`)\nuses a host `DataSurfaceRegistry`'s mounted descriptors (none: plain chat).\nActions: `normalizeDataSurfaceActionRequest` → `AssistantActionClient`\n(preview/apply over a server `DataSurfaceActionAdapter`, not the\n`data-surface-bridge.ts` channel). Generated `ChatThread`/`ChatMessage`\n`list`/`get` are tenant- not member-scoped; the dock never calls them.\n\n**Server routes (#3368).** `./sveltekit` `mountAssistantRoutes()` serves the\ndock from one `[...path]` route; `createAssistantHttp{Transport,ActionClient}`\n(`./svelte`) are its browser half. Principal from `event.locals` or\n`resolvePrincipal`; threads only from the actor's keyed session room in that\ntenant (else 404). `allowedTools` is fail-closed; without `tools` it offers\nthose manifest ops (an unprovided name is an error, #3414). `ai` defaults to\nthe config `ai` block. Pass `runtime`: a turn never keeps the RLS request tx\n(own `runAsPrincipal` tx). `clientRequestId` makes the row id a UUIDv5\n(`clientRequestMessageId`): the PK is the retry reservation. See\n[docs](../../docs/assistant-dock.md).\n\nStructured questions and page requests (#4100): [agents/assistant-questions.md](agents/assistant-questions.md).\n\n## Recipes (#3719)\n\n`src/recipes.ts` declares `chat.assistant` (the AssistantDock as a `header.end`\nshell widget, an `llm` provider, runtime `both`; help in\n`src/assistant.recipe.md`). Keep each recipe class self-contained. Details:\n[agents/recipes.md](agents/recipes.md).\n\n## Gotchas\n\n- **sessionContext, not context**: `context` is reserved for slug scoping. Use `getSessionContext()`/`updateSessionContext()` for agent memory.\n- **Agent rooms auto-created**: `roomType: 'agent'`, `maxParticipants` defaults to 2; the agent is enrolled as a member so its replies pass the membership check. `createAgentSession()` re-enrolls the participant AND the agent on the existing-session path, so legacy sessions created before the agent was enrolled self-heal.\n- **The agent's author is a Profile, not `agentId` (#2995)**: `senderProfileId`/`profileId` are `crossPackageRef`s to `Profile` and therefore uuid columns on PostgreSQL, so the slug fails the cast (`22P02`) and no agent reply can persist. `createAgentSession()` resolves the agent's `bot` Profile through `resolveAgentProfile()` in `@happyvertical/smrt-profiles` (created on first use, tenant-bound) and records it as `AgentSession.agentProfileId`; both enrolment and `sendAgentReply` use that uuid. A consumer whose agent already has an acting identity passes `agentProfileId` to skip resolution — the voice path does this with the persona's `actsAsProfileId`, so persona replies stay attributed to the configured acting profile. Like `actorProfileId`, `agentProfileId` is SERVER-supplied, never request input: it selects the author of every later assistant message. It is validated (`#requireAgentProfile`) to exist and be visible in the tenant and to differ from the acting participant, and it can never re-point a session that already resolved one — only a pre-#2995 session with none is backfilled. **Migration**: `agent_profile_id` is an ordinary additive manifest field, so a consumer adopts it the same way as any other — `smrt db:migrate` generates and applies the column from the manifest, and `db:status --parity` verifies it; the framework never creates application schema at runtime. It is nullable and needs no offline DATA backfill — a session created before #2995 resolves and persists its `agentProfileId`, and re-enrols the agent, on its first agent turn after the upgrade. Two pre-#2995 cohorts: a session whose `agentId` is an ordinary slug never committed an author on PostgreSQL (the cast failed), so there is nothing to repair there, while SQLite/DuckDB may hold `chat_messages`/`chat_participants` rows whose id column holds the slug — readable, but not joinable to `profiles`. A session whose `agentId` is already a Profile uuid (the voice path used to collapse a persona's `actsAsProfileId` into it) DID commit on PostgreSQL; that profile is ADOPTED as the author, tenant-checked and never the participant, so the conversation's author does not change and the two-seat room gains no third identity. Known one-time effect: because `voice.ts` no longer derives `agentId` from `actsAsProfileId`, a keyless `createVoiceChatSession()` for such a persona opens a new session and room instead of reusing the legacy one; the old room and its messages stay intact and readable, and resuming with an explicit `agentSessionId` is unaffected.\n- **Only SQLite-backed tests would miss this class of bug**: uuid columns accept any text on SQLite. `src/__tests__/agent-reply-postgres.test.ts` (`pnpm --filter @happyvertical/smrt-chat test:postgres`) pins the agent-reply path against real PostgreSQL. That suite runs in `postgres-tests.yml` on every same-repository PR and merge group (#2659); keep a cheap SQLite assertion alongside it anyway, since the default lane is SQLite-only (`chat-service.test.ts` asserts the author is the profile uuid, not the slug).\n- **Per-subject sessions need `sessionKey`**: `createAgentSession()` reuses ANY active session for the same `(agentId, participantProfileId, tenantId)`. Callers that open separate conversations per subject (e.g. one content-editor session per content id) MUST pass a stable `sessionKey` (stored in `sessionContext.__sessionKey`, read via `AgentSession.getSessionKey()`); otherwise a session opened for one subject is reused and its context overwritten for another, surfacing the wrong room/threads (S5 #1392). A keyed create never reuses a keyless/legacy session.\n- **Session expiry**: check `isActive()` before allowing messages (expiresAt or limit-based)\n- **DM identity**: derived from the deterministic per-tenant `canonicalDmRoomId()` and the authoritative `chat_participants` join, not client metadata; concurrent creates upsert onto one row.\n- **Tenant-bound lookups**: membership/session/DM lookups REQUIRE `tenantId` and always bind it into the WHERE clause (`findActiveMembership`/`isActiveMember`/`findActiveSession` take a required `tenantId`; AgentSession's `null` tenant is an explicit bound scope, not \"any tenant\") so they can never resolve a row from another tenant.\n",
4516
4517
  "moduleDocs": [
4517
4518
  {
4518
4519
  "path": "agents/dev-workbench.md",
@@ -4529,6 +4530,11 @@
4529
4530
  "module": "token-streaming",
4530
4531
  "content": "# Token Streaming (SSE, #1936)\n\nReference linked from [../AGENTS.md](../AGENTS.md). This contract was moved from\n`packages/chat/AGENTS.md` to keep the instruction chain under the 32 KB cap\n(`pnpm check:agents-chain`); AGENTS.md retains the cap-oriented summary.\n\n`chat-stream.ts` is the SSE seam for embeddable conversational UIs (first consumer: the Happy chat widget, `animation#5`): a client POSTs the conversation so far and receives a `text/event-stream` of `data: <json>` frames — `token` deltas as the model generates, then a final `done` frame with the message. The wire `ChatStreamEvent` union also declares `emotion` and `control` (#1921 host-page control commands) lanes for forward compatibility; the v1 engine emits `token`/`done`/`error`.\n\n- **`runChatConversationStream({ context, messages })`** — the transport-agnostic engine (an `AsyncGenerator<ChatStreamEvent>`). Dispatches on `context.binding`: **persona-bound** runs the full `runPersonaConversationTurn` with a token sink wired through the tool loop (`onToken` → `ai.chat({ stream: true, onProgress })`), then persists via `ChatService` and emits the persisted message as `done`; **plain/unbound** streams `ai.stream()` directly and emits a synthesized (unpersisted) `done`. Streamed tokens are a live PREVIEW (a tool-call round may narrate before acting); the `done` message is authoritative. Failures surface as an in-band `error` event, never a throw (the 200 has already committed once streaming starts).\n- **`createChatStreamHandler({ authorize, allowedOrigins?, allowCredentials? })`** — a Fetch-compatible handler returning `text/event-stream` (mirrors `createVoiceGatewayTurnHandler`). `authorize(request, body)` is the SOLE trust boundary and works exactly like the voice gateway: this module NEVER authorizes from the request's `session` metadata — the app validates the caller (bearer session id / cookie / same-origin) and the claimed ids against the authenticated principal, and returns an already-authorized `ChatStreamContext`. Generation caps (`model`/`maxTokens`/`maxSteps`) live on the context (server-resolved), never on the request. Cross-origin embedding uses the same fail-closed CORS posture as core `_events` (#1861): the `Origin` is echoed only when allow-listed (never `*`), credentials only when opted in.\n- **`SmrtChatBackend` (`@happyvertical/smrt-chat/client`)** — the consume side of the same contract: a browser SSE client (`src/client.ts`) that POSTs the conversation and dispatches `token`/`emotion`/`done`/`error` frames to streaming handlers, tolerating heartbeat comments and frames split across chunks. The subpath is BROWSER-SAFE and dependency-free (no server runtime, no workspace imports — keep it that way), and its widget-facing types are structurally identical to `@happyvertical/animation`'s `ChatBackend` contract so an instance plugs straight into the floating chat widget. `src/client.contract.ts` carries the compile-time locks pinning it to `chat-stream.ts`'s `ChatStreamEvent`/`ChatStreamSession` — a NON-test module precisely so `pnpm typecheck` actually enforces them (`tsconfig.typecheck.json` excludes `.test.ts` files, and Vitest transpiles without typechecking). A clean close without a `done` frame surfaces as an error (never an empty reply), and a settled turn cancels the reader so the connection is released promptly.\n- **Persona path reuses the harness's own gates unchanged** — persona principal, fail-closed `allowedTools` offer+execution gates, tenant binding. `onToken` is best-effort telemetry threaded through `runToolLoop`; it never changes what the loop persists or authorizes.\n- **Custom tools stream via `binding.extraTools`** — the persona binding threads an optional `extraTools?: PrincipalTool[]` down to `runPersonaConversationTurn`, so a *streamed* persona chat can offer non-manifest, service-backed tools (the persona messaging tool `messages.send`, or an assistance-request/lead-ticket tool wrapping a `@smrt({ api:false, mcp:false })` service) and thus *act*, not only answer — matching the non-streaming persona path. It is resolved server-side by `authorize` (trusted), never from request input, and stays fully gated: each tool is filtered by the persona's `allowedTools` (offer gate) and re-asserts the bound principal's authority in `execute` (execution gate). Offering a tool is not authorizing it.\n"
4531
4532
  },
4533
+ {
4534
+ "path": "agents/assistant-questions.md",
4535
+ "module": "assistant-questions",
4536
+ "content": "# Structured assistant questions (#4100)\n\nPart of the [smrt-chat guidance](../AGENTS.md).\n\nAn assistant turn that asks the person to choose is data, not prose. A\ntransport attaches an `{ kind: 'assistant-question', id, prompt, fields }`\nobject to an assistant message's `toolCallData` (alone or in an array). Fields\nare choice fields: `options` (`value`, `label`, `hint?`, `keywords?`),\n`multiple?`, `default?`, `required?`. `assistant-question.ts` validates it\n(`parseAssistantQuestion`, `assistantQuestionFromMessage`), seeds defaults,\nvalidates answers and describes them in one line.\n\nThe dock renders structured cards (a question, a tool result component, a\nhost's `toolCall` content) under the message's text bubble at the full thread\nwidth; plain text stays in bubbles. `AssistantDock` renders it as `AssistantQuestionForm`: one fieldset whose\nlegend is the prompt; a single choice is a smrt-ui radio group (a select past\nsix options), several choices are checkboxes; the default is preselected and\nfocus moves into the newest form. Once answered it is a read-only summary.\n\nSubmitting calls the optional `AssistantTransport.answerQuestion({ threadId,\nmessageId, questionId, values, clientRequestId })`, so the transport continues\nthe turn in code with no model round-trip, and the dock merges its result like\na send. A transport without it gets the answer as an ordinary message\n(`describeAssistantQuestionAnswer`, \"Group by: Status\"). A typed reply still\nworks: `matchAssistantQuestionText` resolves free text against the options\n(shared words with label, value, hint and keywords; earlier option on a tie).\n\nOptions always come from code that knows the domain (a report source's\ndimensions, for example), never from a model's free text. The planner's\nruntime-report follow-ups are the first user.\n\n## Page requests\n\n`AssistantDock` takes smrt-ui's `requestAssistant(element, { prompt })` (the bubbling,\ncancelable `smrt-assistant-request` event), as a \"New report\" button sends: it fills the\ncomposer with the prompt and focuses it, never sends it, and marks the event taken\n(`preventDefault`), so the requester knows an assistant heard it. A request another listener\nalready took is left alone. smrt-svelte's `AdminShell` opens a closed dock first and\nre-dispatches the request from inside it, because a dock's content mounts only when open.\n"
4537
+ },
4532
4538
  {
4533
4539
  "path": "agents/recipes.md",
4534
4540
  "module": "recipes",
@@ -8,7 +8,11 @@
8
8
  * `DataSurfaceRegistry` instance (already owned by the host shell) and an
9
9
  * `AssistantTransport`.
10
10
  */
11
- import { MessageBubble } from '@happyvertical/smrt-ui/chat';
11
+ import {
12
+ ASSISTANT_REQUEST_EVENT,
13
+ type AssistantRequestDetail,
14
+ MessageBubble,
15
+ } from '@happyvertical/smrt-ui/chat';
12
16
  import type {
13
17
  DataSurfaceActionRequest,
14
18
  DataSurfaceActionResult,
@@ -30,9 +34,15 @@ import ToolCallDisplay from '../agent/ToolCallDisplay.svelte';
30
34
  import ModelPicker from '../shared/ModelPicker.svelte';
31
35
  import AssistantChoiceCards from './AssistantChoiceCards.svelte';
32
36
  import AssistantComposer from './AssistantComposer.svelte';
37
+ import AssistantQuestionForm from './AssistantQuestionForm.svelte';
33
38
  import AssistantThreadList from './AssistantThreadList.svelte';
34
39
  import { toolCallStatusForAction } from './action-status.js';
35
40
  import type { AssistantChoiceSourceRegistry } from './assistant-choices.svelte.js';
41
+ import {
42
+ type AssistantQuestion,
43
+ type AssistantQuestionAnswer,
44
+ assistantQuestionFromMessage,
45
+ } from './assistant-question.js';
36
46
  import type {
37
47
  AssistantAttachmentRef,
38
48
  AssistantMessage,
@@ -520,6 +530,23 @@ $effect(() => {
520
530
  void tick().then(() => composerRef?.focus());
521
531
  });
522
532
 
533
+ // The page asks the assistant (`requestAssistant` from smrt-ui: a "New
534
+ // report" button, an empty state): put the prompt in the composer for the
535
+ // person to edit and send, never sending it on their behalf. A shell that
536
+ // opens this dock first re-dispatches the request from inside it.
537
+ $effect(() => {
538
+ if (typeof document === 'undefined') return;
539
+ const take = (event: Event) => {
540
+ const detail = (event as CustomEvent<AssistantRequestDetail>).detail;
541
+ if (event.defaultPrevented || typeof detail?.prompt !== 'string') return;
542
+ event.preventDefault();
543
+ controller.setDraft(detail.prompt);
544
+ void tick().then(() => composerRef?.focus());
545
+ };
546
+ document.addEventListener(ASSISTANT_REQUEST_EVENT, take);
547
+ return () => document.removeEventListener(ASSISTANT_REQUEST_EVENT, take);
548
+ });
549
+
523
550
  async function handleClearConversation() {
524
551
  closeThreads();
525
552
  controller.setError(null);
@@ -547,6 +574,23 @@ async function handleCreateThread() {
547
574
  }
548
575
  }
549
576
 
577
+ // A structured question's form (`./assistant-question.ts`): the transport
578
+ // continues the turn from the values, or they go out as a message. A failure
579
+ // is surfaced on the dock and the form stays answerable.
580
+ async function handleAnswerQuestion(
581
+ messageId: string,
582
+ question: AssistantQuestion,
583
+ values: AssistantQuestionAnswer,
584
+ ) {
585
+ try {
586
+ await controller.answerQuestion(messageId, question, values);
587
+ } catch (error) {
588
+ controller.setError(error instanceof Error ? error.message : String(error));
589
+ // The form learns it failed, so it stays answerable.
590
+ throw error;
591
+ }
592
+ }
593
+
550
594
  async function handleSend(
551
595
  content: string,
552
596
  attachments: AssistantAttachmentRef[],
@@ -771,23 +815,21 @@ async function handleConfirmAction(requestId: string) {
771
815
 
772
816
  <ul class="assistant-dock-messages">
773
817
  {#each controller.messages as message (message.id)}
818
+ {@const ResultComponent = message.role === 'tool' ? toolResultComponent?.(message) : undefined}
819
+ {@const question = message.role === 'assistant' ? assistantQuestionFromMessage(message) : null}
774
820
  <li data-role={message.role}>
775
- <MessageBubble
776
- variant={bubbleVariant(message.role)}
777
- own={message.role === 'user'}
778
- >
779
- {#snippet children()}
780
- {@const ResultComponent = message.role === 'tool' ? toolResultComponent?.(message) : undefined}
781
- {#if ResultComponent}
782
- <ResultComponent {message} />
783
- {:else}
821
+ {#if ResultComponent}
822
+ <!-- Structured results are cards spanning the thread, not bubbles. -->
823
+ <div class="assistant-dock-card">
824
+ <ResultComponent {message} />
825
+ </div>
826
+ {:else}
827
+ <MessageBubble
828
+ variant={bubbleVariant(message.role)}
829
+ own={message.role === 'user'}
830
+ >
831
+ {#snippet children()}
784
832
  <p class="assistant-dock-message-content">{message.content}</p>
785
- {/if}
786
- {#if toolCall && message.toolCallData != null}
787
- <div class="assistant-dock-tool-call">
788
- {@render toolCall(message)}
789
- </div>
790
- {/if}
791
833
  {#if message.attachments && message.attachments.length > 0}
792
834
  <ul
793
835
  class="assistant-dock-attachments"
@@ -819,8 +861,26 @@ async function handleConfirmAction(requestId: string) {
819
861
  {/each}
820
862
  </ul>
821
863
  {/if}
822
- {/snippet}
823
- </MessageBubble>
864
+ {/snippet}
865
+ </MessageBubble>
866
+ {/if}
867
+ {#if question}
868
+ <div class="assistant-dock-card">
869
+ <AssistantQuestionForm
870
+ {question}
871
+ answered={controller.answeredQuestions[`${message.id}:${question.id}`] ?? null}
872
+ autofocus={message.id === controller.messages.at(-1)?.id}
873
+ onsubmit={(values) => handleAnswerQuestion(message.id, question, values)}
874
+ />
875
+ </div>
876
+ {/if}
877
+ {#if toolCall && message.toolCallData != null}
878
+ <!-- A host's cards (a report preview, an offer) span the thread
879
+ under the message's text bubble. -->
880
+ <div class="assistant-dock-card assistant-dock-tool-call">
881
+ {@render toolCall(message)}
882
+ </div>
883
+ {/if}
824
884
  </li>
825
885
  {/each}
826
886
  </ul>
@@ -1267,9 +1327,13 @@ async function handleConfirmAction(requestId: string) {
1267
1327
  min-height: 4rem;
1268
1328
  }
1269
1329
 
1270
- .assistant-dock-tool-call {
1330
+ /* Structured cards (results, questions, a host's previews) use the full
1331
+ * thread width; the components inside draw their own card surface. */
1332
+ .assistant-dock-card {
1333
+ align-self: stretch;
1334
+ inline-size: 100%;
1335
+ min-inline-size: 0;
1271
1336
  margin-top: var(--smrt-spacing-2);
1272
- min-width: 0;
1273
1337
  }
1274
1338
 
1275
1339
  .assistant-dock-message-content {
@@ -1 +1 @@
1
- {"version":3,"file":"AssistantDock.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantDock.svelte.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AssistantDock.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantDock.svelte.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,245 @@
1
+ <script lang="ts">
2
+ /**
3
+ * AssistantQuestionForm — a structured assistant question (see
4
+ * `./assistant-question.ts`) as a small form in the chat (#4100).
5
+ *
6
+ * One fieldset whose legend is the question. A single choice is a radio group
7
+ * (a select past six options), several choices are checkboxes, defaults are
8
+ * preselected, and one submit button sends the values. Once answered it
9
+ * collapses to a read-only summary line. With `autofocus` the first control
10
+ * takes focus when the form appears, so a keyboard user can answer at once.
11
+ */
12
+
13
+ import {
14
+ Checkbox,
15
+ Fieldset,
16
+ Form,
17
+ Radio,
18
+ RadioGroup,
19
+ Select,
20
+ } from '@happyvertical/smrt-ui/forms';
21
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
22
+ import { Button } from '@happyvertical/smrt-ui/ui';
23
+ import { M } from '../../i18n.js';
24
+ import {
25
+ type AssistantQuestion,
26
+ type AssistantQuestionAnswer,
27
+ assistantQuestionDefaults,
28
+ describeAssistantQuestionAnswer,
29
+ validateAssistantQuestionAnswer,
30
+ } from './assistant-question.js';
31
+
32
+ export interface Props {
33
+ /** The structured question to ask: its prompt, fields and options. */
34
+ question: AssistantQuestion;
35
+ /** The answer, when given (from the question itself or the host). */
36
+ answered?: AssistantQuestionAnswer | null;
37
+ /** Called once with the chosen values; the host continues the turn. */
38
+ onsubmit: (values: AssistantQuestionAnswer) => void | Promise<void>;
39
+ /** Move focus into the form when it appears (the newest message). */
40
+ autofocus?: boolean;
41
+ /** Lock the controls (a turn is in flight elsewhere). */
42
+ disabled?: boolean;
43
+ }
44
+
45
+ const {
46
+ question,
47
+ answered = null,
48
+ onsubmit,
49
+ autofocus = false,
50
+ disabled = false,
51
+ }: Props = $props();
52
+ const { t } = useI18n();
53
+ const RADIO_LIMIT = 6;
54
+ const formId = $props.id();
55
+
56
+ // Seeded once from the question's defaults; the person edits from there.
57
+ let values = $state<AssistantQuestionAnswer>(
58
+ assistantQuestionDefaults((() => question)()),
59
+ );
60
+ let submitting = $state(false);
61
+ let submitted = $state<AssistantQuestionAnswer | null>(null);
62
+ let container = $state<HTMLDivElement | null>(null);
63
+
64
+ const answer = $derived(answered ?? question.answer ?? submitted);
65
+ const valid = $derived(validateAssistantQuestionAnswer(question, values));
66
+
67
+ $effect(() => {
68
+ if (autofocus && container && !answer) {
69
+ container
70
+ .querySelector<HTMLElement>('input:not(:disabled), select:not(:disabled)')
71
+ ?.focus();
72
+ }
73
+ });
74
+
75
+ function setSingle(name: string, value: string) {
76
+ values = { ...values, [name]: value };
77
+ }
78
+
79
+ function toggle(name: string, value: string, on: boolean) {
80
+ const current = values[name];
81
+ const list = Array.isArray(current) ? current : current ? [current] : [];
82
+ values = {
83
+ ...values,
84
+ [name]: on
85
+ ? [...new Set([...list, value])]
86
+ : list.filter((v) => v !== value),
87
+ };
88
+ }
89
+
90
+ const isChecked = (name: string, value: string) => {
91
+ const current = values[name];
92
+ return Array.isArray(current) ? current.includes(value) : current === value;
93
+ };
94
+
95
+ async function submit() {
96
+ const chosen = validateAssistantQuestionAnswer(question, values);
97
+ if (!chosen || submitting || answer) return;
98
+ submitting = true;
99
+ try {
100
+ await onsubmit(chosen);
101
+ submitted = chosen;
102
+ } catch {
103
+ // The host surfaced the failure; the form stays answerable.
104
+ } finally {
105
+ submitting = false;
106
+ }
107
+ }
108
+ </script>
109
+
110
+ <div
111
+ bind:this={container}
112
+ class="assistant-question"
113
+ data-smrt-component="AssistantQuestionForm"
114
+ >
115
+ {#if answer}
116
+ <p class="assistant-question-summary" role="status">
117
+ <span class="assistant-question-prompt">{question.prompt}</span>
118
+ {describeAssistantQuestionAnswer(question, answer)}
119
+ </p>
120
+ {:else}
121
+ <Form class="assistant-question-form" onsubmit={submit} aria-label={question.prompt}>
122
+ <Fieldset legend={question.prompt} stack>
123
+ {#snippet children()}
124
+ {#each question.fields as field (field.name)}
125
+ {#if field.multiple}
126
+ <Fieldset legend={field.label} stack>
127
+ {#snippet children()}
128
+ {#each field.options as option (option.value)}
129
+ <Checkbox
130
+ name={`${formId}-${field.name}`}
131
+ value={option.value}
132
+ label={option.label}
133
+ checked={isChecked(field.name, option.value)}
134
+ {disabled}
135
+ onchange={(e: Event & { currentTarget: HTMLInputElement }) =>
136
+ toggle(field.name, option.value, e.currentTarget.checked)}
137
+ />
138
+ {/each}
139
+ {/snippet}
140
+ </Fieldset>
141
+ {:else if field.options.length > RADIO_LIMIT}
142
+ <label class="assistant-question-select">
143
+ <span>{field.label}</span>
144
+ <Select
145
+ name={`${formId}-${field.name}`}
146
+ value={typeof values[field.name] === 'string' ? (values[field.name] as string) : ''}
147
+ required={field.required}
148
+ {disabled}
149
+ interaction={false}
150
+ onchange={(e: Event & { currentTarget: HTMLSelectElement }) =>
151
+ setSingle(field.name, e.currentTarget.value)}
152
+ >
153
+ {#snippet children()}
154
+ {#if values[field.name] === undefined}
155
+ <option value="" disabled>{t(M['chat.assistant_question.choose'])}</option>
156
+ {/if}
157
+ {#each field.options as option (option.value)}
158
+ <option value={option.value}>{option.label}</option>
159
+ {/each}
160
+ {/snippet}
161
+ </Select>
162
+ </label>
163
+ {:else}
164
+ <RadioGroup
165
+ name={`${formId}-${field.name}`}
166
+ label={field.label}
167
+ value={typeof values[field.name] === 'string' ? (values[field.name] as string) : ''}
168
+ required={field.required}
169
+ {disabled}
170
+ interaction={false}
171
+ onvaluechange={(value) => setSingle(field.name, value)}
172
+ >
173
+ {#snippet children()}
174
+ {#each field.options as option (option.value)}
175
+ <Radio value={option.value} label={option.label} />
176
+ {/each}
177
+ {/snippet}
178
+ </RadioGroup>
179
+ {/if}
180
+ {/each}
181
+ {/snippet}
182
+ </Fieldset>
183
+ <div class="assistant-question-actions">
184
+ <Button type="submit" disabled={disabled || submitting || !valid}>
185
+ {question.submitLabel ?? t(M['chat.assistant_question.submit'])}
186
+ </Button>
187
+ </div>
188
+ </Form>
189
+ {/if}
190
+ </div>
191
+
192
+ <style>
193
+ @layer smrt.reset, smrt.structure, smrt.theme;
194
+ @layer smrt.theme {
195
+ /* A card spanning the thread, like the other structured cards. */
196
+ .assistant-question {
197
+ box-sizing: border-box;
198
+ inline-size: 100%;
199
+ padding: var(--smrt-spacing-3);
200
+ border: var(--smrt-hairline);
201
+ border-radius: var(--smrt-radius-large);
202
+ background: var(--smrt-color-surface-container);
203
+ color: var(--smrt-color-on-surface);
204
+ }
205
+
206
+ .assistant-question :global(.assistant-question-form) {
207
+ display: grid;
208
+ gap: var(--smrt-spacing-3);
209
+ }
210
+
211
+ .assistant-question :global(fieldset) {
212
+ min-inline-size: 0;
213
+ margin: 0;
214
+ }
215
+
216
+ .assistant-question-select {
217
+ display: grid;
218
+ gap: var(--smrt-spacing-1);
219
+ font: var(--smrt-typography-label-large-font);
220
+ }
221
+
222
+ .assistant-question-select :global(select) {
223
+ inline-size: 100%;
224
+ }
225
+
226
+ /* Actions sit at the end, like a report card's; a narrow dock gives the
227
+ button the whole row. */
228
+ .assistant-question-actions {
229
+ display: flex;
230
+ justify-content: flex-end;
231
+ }
232
+
233
+ .assistant-question-summary {
234
+ margin: 0;
235
+ color: var(--smrt-color-on-surface-variant);
236
+ font: var(--smrt-typography-body-small-font);
237
+ }
238
+
239
+ .assistant-question-prompt {
240
+ display: block;
241
+ color: var(--smrt-color-on-surface);
242
+ font: var(--smrt-typography-label-large-font);
243
+ }
244
+ }
245
+ </style>
@@ -0,0 +1,17 @@
1
+ import { type AssistantQuestion, type AssistantQuestionAnswer } from './assistant-question.js';
2
+ export interface Props {
3
+ /** The structured question to ask: its prompt, fields and options. */
4
+ question: AssistantQuestion;
5
+ /** The answer, when given (from the question itself or the host). */
6
+ answered?: AssistantQuestionAnswer | null;
7
+ /** Called once with the chosen values; the host continues the turn. */
8
+ onsubmit: (values: AssistantQuestionAnswer) => void | Promise<void>;
9
+ /** Move focus into the form when it appears (the newest message). */
10
+ autofocus?: boolean;
11
+ /** Lock the controls (a turn is in flight elsewhere). */
12
+ disabled?: boolean;
13
+ }
14
+ declare const AssistantQuestionForm: import("svelte").Component<Props, {}, "">;
15
+ type AssistantQuestionForm = ReturnType<typeof AssistantQuestionForm>;
16
+ export default AssistantQuestionForm;
17
+ //# sourceMappingURL=AssistantQuestionForm.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssistantQuestionForm.svelte.d.ts","sourceRoot":"","sources":["../../../../src/svelte/components/assistant/AssistantQuestionForm.svelte.ts"],"names":[],"mappings":"AAwBA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,EAI7B,MAAM,yBAAyB,CAAC;AAGjC,MAAM,WAAW,KAAK;IACpB,sEAAsE;IACtE,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,uBAAuB,GAAG,IAAI,CAAC;IAC1C,uEAAuE;IACvE,QAAQ,EAAE,CAAC,MAAM,EAAE,uBAAuB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,qEAAqE;IACrE,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,yDAAyD;IACzD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwID,QAAA,MAAM,qBAAqB,2CAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}