@axiom-lattice/protocols 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/protocols",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
4
4
  "description": "Unified protocol type definitions for Axiom Lattice framework",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -47,9 +47,9 @@ export interface UpdateCapabilityBundleInput extends Partial<CreateCapabilityBun
47
47
  expectedUpdatedAt: string;
48
48
  }
49
49
 
50
- /** Store input retained for internal maintenance callers that may omit CAS. */
50
+ /** Store input for updating a bundle; the stable key is immutable and the revision is always required for CAS. */
51
51
  export interface InternalUpdateCapabilityBundleInput extends Partial<CreateCapabilityBundleInput> {
52
- expectedUpdatedAt?: string;
52
+ expectedUpdatedAt: string;
53
53
  }
54
54
 
55
55
  /** Result returned when an update's expected revision no longer matches. */
@@ -70,8 +70,7 @@ export interface CapabilityBundleStore {
70
70
  getManyByIds(tenantId: string, ids: string[]): Promise<CapabilityBundle[]>;
71
71
  /** Creates a bundle for a tenant. */
72
72
  create(tenantId: string, input: InternalCreateCapabilityBundleInput): Promise<CapabilityBundle>;
73
- /** Updates a bundle, or returns null when it does not exist. */
74
- /** Omitted expectedUpdatedAt is reserved for internal maintenance callers. */
73
+ /** Updates a bundle with mandatory revision CAS, or returns null when it does not exist. */
75
74
  update(tenantId: string, id: string, input: InternalUpdateCapabilityBundleInput): Promise<CapabilityBundle | CapabilityBundleUpdateConflict | null>;
76
75
  /** Atomically deletes a bundle unless a tenant project references it. */
77
76
  deleteIfUnreferenced(tenantId: string, id: string): Promise<CapabilityBundleDeleteResult>;
@@ -0,0 +1,76 @@
1
+ /** Display modes supported by MCP Apps. */
2
+ export type McpUiDisplayMode = "inline" | "fullscreen" | "pip";
3
+
4
+ /** CSP origins declared by a UI resource. */
5
+ export interface McpUiCsp {
6
+ connectDomains?: string[];
7
+ resourceDomains?: string[];
8
+ frameDomains?: string[];
9
+ baseUriDomains?: string[];
10
+ }
11
+
12
+ /** Sandbox permissions requested by a UI resource. */
13
+ export interface McpUiPermissions {
14
+ camera?: boolean;
15
+ microphone?: boolean;
16
+ geolocation?: boolean;
17
+ clipboardWrite?: boolean;
18
+ }
19
+
20
+ /** A UI ref carried in the `mcp_app` content fence. */
21
+ export type McpUiRef =
22
+ | {
23
+ kind: "mcp";
24
+ serverKey: string;
25
+ resourceUri: string;
26
+ displayMode: McpUiDisplayMode;
27
+ title?: string;
28
+ /**
29
+ * Retained only for backward compatibility with pre-unification
30
+ * messages; no longer emitted. Rich tool results now travel via
31
+ * `ToolCall.ui`.
32
+ */
33
+ structuredContent?: unknown;
34
+ }
35
+ | { kind: "plugin"; pluginType: string; resource: string; displayMode: McpUiDisplayMode; title?: string };
36
+
37
+ /** Resolved UI resource body returned by the gateway. */
38
+ export interface McpUiResourcePayload {
39
+ mimeType: string;
40
+ html: string;
41
+ csp?: McpUiCsp;
42
+ permissions?: McpUiPermissions;
43
+ prefersBorder?: boolean;
44
+ stale?: boolean;
45
+ }
46
+
47
+ const MODES: McpUiDisplayMode[] = ["inline", "fullscreen", "pip"];
48
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
49
+
50
+ /** Type guard for a parsed `McpUiRef`. */
51
+ export function isMcpUiRef(value: unknown): value is McpUiRef {
52
+ return parseMcpUiRef(value) !== null;
53
+ }
54
+
55
+ /** Parse and normalize an unknown value into a `McpUiRef`, or `null`. */
56
+ export function parseMcpUiRef(value: unknown): McpUiRef | null {
57
+ if (!isObj(value)) return null;
58
+ const displayMode = MODES.includes(value.displayMode as McpUiDisplayMode)
59
+ ? (value.displayMode as McpUiDisplayMode)
60
+ : "inline";
61
+ const title = typeof value.title === "string" ? value.title : undefined;
62
+ if (value.kind === "mcp" && typeof value.serverKey === "string" && typeof value.resourceUri === "string") {
63
+ return {
64
+ kind: "mcp",
65
+ serverKey: value.serverKey,
66
+ resourceUri: value.resourceUri,
67
+ displayMode,
68
+ ...(title ? { title } : {}),
69
+ ...(value.structuredContent !== undefined ? { structuredContent: value.structuredContent } : {}),
70
+ };
71
+ }
72
+ if (value.kind === "plugin" && typeof value.pluginType === "string" && typeof value.resource === "string") {
73
+ return { kind: "plugin", pluginType: value.pluginType, resource: value.resource, displayMode, ...(title ? { title } : {}) };
74
+ }
75
+ return null;
76
+ }
@@ -5,9 +5,12 @@
5
5
  * Menu items are merged with built-in defaults at the React SDK layer.
6
6
  */
7
7
 
8
+ import type { McpUiDisplayMode } from "./McpUiProtocol";
9
+ import type { ToolCallUi } from "./MessageProtocol";
10
+
8
11
  export type MenuTarget = 'sidebar' | 'workspace';
9
12
 
10
- export type MenuContentType = 'agent' | 'html' | 'custom';
13
+ export type MenuContentType = 'agent' | 'html' | 'custom' | 'file' | 'mcpApp';
11
14
 
12
15
  export interface AgentMenuConfig {
13
16
  agentId: string;
@@ -25,7 +28,52 @@ export interface CustomMenuConfig {
25
28
  componentKey: string;
26
29
  }
27
30
 
28
- export type MenuContentConfig = AgentMenuConfig | HtmlMenuConfig | CustomMenuConfig;
31
+ /**
32
+ * A pinned MCP App menu item.
33
+ *
34
+ * The identity (`kind` + server/plugin + resource) is enough to re-open the
35
+ * App; `toolInput` lets a self-refreshing App re-run the same query. The pin
36
+ * flow also captures the current `toolResult` and `ui` so the pinned view has
37
+ * the same initial data as the chat view did (an App that refreshes itself can
38
+ * still re-query).
39
+ */
40
+ export type McpAppMenuConfig =
41
+ | {
42
+ kind: 'mcp';
43
+ serverKey: string;
44
+ resourceUri: string;
45
+ displayMode?: McpUiDisplayMode;
46
+ title?: string;
47
+ toolInput?: Record<string, unknown>;
48
+ toolResult?: unknown;
49
+ ui?: ToolCallUi;
50
+ }
51
+ | {
52
+ kind: 'plugin';
53
+ pluginType: string;
54
+ resource: string;
55
+ displayMode?: McpUiDisplayMode;
56
+ title?: string;
57
+ toolInput?: Record<string, unknown>;
58
+ toolResult?: unknown;
59
+ ui?: ToolCallUi;
60
+ };
61
+
62
+ /** A pinned workspace file rendered by the file viewer. */
63
+ export interface FileMenuConfig {
64
+ resourcePath: string;
65
+ fileName?: string;
66
+ workspaceId?: string;
67
+ projectId?: string;
68
+ assistantId?: string;
69
+ }
70
+
71
+ export type MenuContentConfig =
72
+ | AgentMenuConfig
73
+ | HtmlMenuConfig
74
+ | CustomMenuConfig
75
+ | FileMenuConfig
76
+ | McpAppMenuConfig;
29
77
 
30
78
  export interface MenuItem {
31
79
  id: string;
@@ -24,6 +24,22 @@ export interface UserMessage extends BaseMessage {
24
24
  files?: Array<{ name: string; id: string }>; // Optional files attached to the message
25
25
  }
26
26
 
27
+ /**
28
+ * UI-only rich payload for a tool call.
29
+ *
30
+ * Populated by the client-side message merger from the tool message's raw
31
+ * LangChain `artifact` and non-text content blocks. It is never part of the
32
+ * model-visible text: the model only sees `ToolCall.response`.
33
+ */
34
+ export interface ToolCallUi {
35
+ /** MCP `CallToolResult.structuredContent`, when the tool produced one. */
36
+ structuredContent?: unknown;
37
+ /** MCP `CallToolResult._meta`, when present. */
38
+ meta?: Record<string, unknown>;
39
+ /** Non-text content blocks (image / audio / resource / file). */
40
+ contentBlocks?: Array<{ type: string; [key: string]: unknown }>;
41
+ }
42
+
27
43
  /**
28
44
  * Tool call interface
29
45
  */
@@ -33,6 +49,12 @@ export interface ToolCall {
33
49
  args: Record<string, any>; // Arguments as an object
34
50
  type: "tool_call"; // Type of tool call
35
51
  response?: string; // Optional response from the tool execution
52
+ /** UI-only rich result (MCP Apps). Never part of the model-visible text. */
53
+ ui?: ToolCallUi;
54
+ /** Execution status reported by the client-side message merger. */
55
+ status?: "success" | "pending" | "error";
56
+ /** Correlation id of the originating tool message (internal). */
57
+ original_tool_message_id?: string;
36
58
  }
37
59
 
38
60
  /**
@@ -106,6 +128,8 @@ export interface MessageChunk {
106
128
  id?: string;
107
129
  index: number;
108
130
  }>;
131
+ // Wire shape: intentionally lacks `ui`, which is client-populated on ToolCall
132
+ // from the tool message's `artifact` and never sent by the server.
109
133
  tool_calls?: Array<{
110
134
  name: string;
111
135
  args: Record<string, any>;
@@ -120,6 +144,12 @@ export interface MessageChunk {
120
144
  }>;
121
145
  };
122
146
  tool_call_id?: string;
147
+ /**
148
+ * Raw LangChain tool message `artifact` (e.g. MCP `structuredContent`
149
+ * and non-text content blocks). Present on `tool` chunks; consumed by the
150
+ * client-side message merger to populate `ToolCall.ui`.
151
+ */
152
+ artifact?: unknown;
123
153
  };
124
154
  }
125
155
 
@@ -20,7 +20,7 @@ export interface OpenExecutionContext {
20
20
  projectId: string;
21
21
  /** Addressing only, not an authorization dimension (see design §6.2). */
22
22
  workspaceId?: string;
23
- /** Synthesized runConfig (mirrors the Agent path, incl. _resolvedConnections). */
23
+ /** Synthesized run identity (mirrors the Agent path's runConfig; plugin tools resolve their own connections). */
24
24
  runConfig: Record<string, unknown>;
25
25
  }
26
26
 
@@ -1,4 +1,5 @@
1
1
  import type { AgentConfig } from "./AgentLatticeProtocol";
2
+ import type { McpUiDisplayMode, McpUiCsp, McpUiPermissions } from "./McpUiProtocol";
2
3
 
3
4
  /**
4
5
  * Plugin Connection 字段 schema(驱动前端表单渲染)
@@ -80,6 +81,37 @@ export interface PluginConnection {
80
81
  resourceLabel?: string;
81
82
  }
82
83
 
84
+ /**
85
+ * UI declaration for a plugin tool: an HTML file rendered as an MCP App when
86
+ * the tool runs.
87
+ *
88
+ * @property resource - Safe relative path (within the plugin directory) to an `.html` file.
89
+ * @property displayMode - Preferred display mode; defaults to `inline` (resolved by the compiler).
90
+ * @property csp - Origins the UI needs (see `McpUiCsp`).
91
+ * @property permissions - Sandbox permissions requested (see `McpUiPermissions`).
92
+ * @property prefersBorder - Visual boundary preference.
93
+ */
94
+ export interface PluginToolUi {
95
+ resource: string;
96
+ displayMode?: McpUiDisplayMode;
97
+ csp?: McpUiCsp;
98
+ permissions?: McpUiPermissions;
99
+ prefersBorder?: boolean;
100
+ }
101
+
102
+ /**
103
+ * Inline UI resource served by a GLOBAL (non-sandbox) plugin.
104
+ *
105
+ * @property html - Full HTML document served for a `ui://` resource.
106
+ * @property mimeType - Defaults to `text/html;profile=mcp-app` when omitted.
107
+ * @property csp - Origins the UI needs.
108
+ */
109
+ export interface PluginUiResource {
110
+ html: string;
111
+ mimeType?: string;
112
+ csp?: McpUiCsp;
113
+ }
114
+
83
115
  /**
84
116
  * 工具元信息(用于前端 allowedTools 筛选)
85
117
  *
@@ -92,6 +124,8 @@ export interface PluginToolMeta {
92
124
  description: string;
93
125
  /** When true, this tool surfaces on the Open API (MCP) surface. */
94
126
  expose?: boolean;
127
+ /** Optional UI (HTML) declaration for this tool. */
128
+ ui?: PluginToolUi;
95
129
  }
96
130
 
97
131
  /** Open-surface exposure entry with MCP annotation hints. */
@@ -163,6 +197,8 @@ export interface PluginMeta {
163
197
  icon?: string;
164
198
  /** 工具清单(可选,middleware 能自动提取时不需要写) */
165
199
  tools?: PluginToolMeta[];
200
+ /** Inline `ui://` resources for this plugin's tool UIs (global plugins only). */
201
+ uiResources?: Record<string, PluginUiResource>;
166
202
  /**
167
203
  * Open 面(MCP)暴露的工具名清单——独立于 tools 声明,一行即生长。
168
204
  * 声明后 OpenCredentialService 的 grants 匹配 domain=<meta.type>。
@@ -1,5 +1,5 @@
1
1
  import type { AgentConfig } from "./AgentLatticeProtocol";
2
- import type { PluginConnectionFieldSchema, PluginSkillDefinition } from "./PluginProtocol";
2
+ import type { PluginConnectionFieldSchema, PluginSkillDefinition, PluginToolUi } from "./PluginProtocol";
3
3
 
4
4
  /**
5
5
  * A single tool contributed by a sandbox-authored tenant plugin.
@@ -19,6 +19,8 @@ export interface SandboxPluginToolDef {
19
19
  destructive?: boolean;
20
20
  timeoutMs?: number;
21
21
  maxResultBytes?: number;
22
+ /** Optional UI (HTML) rendered as an MCP App when this tool runs. */
23
+ ui?: PluginToolUi;
22
24
  }
23
25
 
24
26
  /**
@@ -331,6 +331,25 @@ export interface UpdateTaskRequest {
331
331
  files?: TaskFileRef[];
332
332
  }
333
333
 
334
+ /**
335
+ * Normalizes a task metadata scalar to its JSON text form so every task store
336
+ * backend compares metadata filters identically.
337
+ *
338
+ * The contract is JSON scalar value equality, type-tolerant: `1` and `"1"`
339
+ * are equal, `true` and `"true"` are equal, and `true` never equals `1`.
340
+ * PostgreSQL's `metadata->>` naturally yields this form; InMemory and SQLite
341
+ * use this helper to match it.
342
+ *
343
+ * @param value Candidate filter or stored metadata value.
344
+ * @returns JSON text form for string/number/boolean, otherwise `undefined`.
345
+ */
346
+ export function normalizeTaskMetadataScalar(value: unknown): string | undefined {
347
+ if (typeof value === "string") return value;
348
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined;
349
+ if (typeof value === "boolean") return value ? "true" : "false";
350
+ return undefined;
351
+ }
352
+
334
353
  /**
335
354
  * Task list filter criteria
336
355
  */
@@ -16,6 +16,12 @@ export interface ProjectRoomTrustedRunContext {
16
16
  role: "coordinator" | "specialist";
17
17
  title: string;
18
18
  responsibility?: string;
19
+ /**
20
+ * Host-provided provenance string, persisted unchanged. Optional; a non-empty string when present;
21
+ * `undefined` is treated as absent. Core copies and compares this value without interpreting or
22
+ * naming any specific value (ADR-118).
23
+ */
24
+ source?: string;
19
25
  }
20
26
 
21
27
  /** Trusted Project Task identity persisted with a privileged queue message. */
@@ -29,6 +35,12 @@ export interface ProjectTaskTrustedRunContext {
29
35
  taskId: string;
30
36
  threadId: string;
31
37
  inputMessageId: string;
38
+ /**
39
+ * Host-provided provenance string, persisted unchanged. Optional; a non-empty string when present;
40
+ * `undefined` is treated as absent. Core copies and compares this value without interpreting or
41
+ * naming any specific value (ADR-118).
42
+ */
43
+ source?: string;
32
44
  }
33
45
 
34
46
  /** Host-authenticated metadata that cannot be supplied through public Agent APIs. */
@@ -54,37 +66,47 @@ export function parseTrustedRunContext(value: unknown): TrustedRunContext {
54
66
  "tenantId", "workspaceId", "projectId", "roomId", "membershipId",
55
67
  "assistantId", "taskId", "threadId", "inputMessageId",
56
68
  ] as const;
57
- const projectTaskValues = snapshotExactRecord(contextValues.projectTask, requiredKeys);
69
+ const projectTaskValues = snapshotExactRecord(contextValues.projectTask, requiredKeys, ["source"]);
70
+ const projectTaskHasSource = projectTaskValues !== undefined
71
+ && Object.prototype.hasOwnProperty.call(projectTaskValues, "source");
58
72
  if (!projectTaskValues
59
- || requiredKeys.some((key) => typeof projectTaskValues[key] !== "string" || projectTaskValues[key].length === 0)) {
73
+ || requiredKeys.some((key) => typeof projectTaskValues[key] !== "string" || projectTaskValues[key].length === 0)
74
+ || (projectTaskHasSource && projectTaskValues.source !== undefined
75
+ && (typeof projectTaskValues.source !== "string" || projectTaskValues.source.length === 0))) {
60
76
  throw new Error("Invalid trusted agent run context");
61
77
  }
62
- return {
63
- projectTask: {
64
- tenantId: projectTaskValues.tenantId as string,
65
- workspaceId: projectTaskValues.workspaceId as string,
66
- projectId: projectTaskValues.projectId as string,
67
- roomId: projectTaskValues.roomId as string,
68
- membershipId: projectTaskValues.membershipId as string,
69
- assistantId: projectTaskValues.assistantId as string,
70
- taskId: projectTaskValues.taskId as string,
71
- threadId: projectTaskValues.threadId as string,
72
- inputMessageId: projectTaskValues.inputMessageId as string,
73
- },
78
+ const parsedProjectTask: ProjectTaskTrustedRunContext = {
79
+ tenantId: projectTaskValues.tenantId as string,
80
+ workspaceId: projectTaskValues.workspaceId as string,
81
+ projectId: projectTaskValues.projectId as string,
82
+ roomId: projectTaskValues.roomId as string,
83
+ membershipId: projectTaskValues.membershipId as string,
84
+ assistantId: projectTaskValues.assistantId as string,
85
+ taskId: projectTaskValues.taskId as string,
86
+ threadId: projectTaskValues.threadId as string,
87
+ inputMessageId: projectTaskValues.inputMessageId as string,
74
88
  };
89
+ if (projectTaskHasSource && typeof projectTaskValues.source === "string") {
90
+ parsedProjectTask.source = projectTaskValues.source;
91
+ }
92
+ return { projectTask: parsedProjectTask };
75
93
  }
76
94
  const projectRoomValue = contextValues?.projectRoom;
77
95
  const requiredKeys = [
78
96
  "tenantId", "workspaceId", "projectId", "roomId", "sourceRoomMessageId", "membershipId",
79
97
  "assistantId", "inputMessageId", "role", "title",
80
98
  ] as const;
81
- const projectRoomValues = snapshotExactRecord(projectRoomValue, requiredKeys, ["responsibility"]);
99
+ const projectRoomValues = snapshotExactRecord(projectRoomValue, requiredKeys, ["responsibility", "source"]);
82
100
  const hasResponsibility = projectRoomValues !== undefined
83
101
  && Object.prototype.hasOwnProperty.call(projectRoomValues, "responsibility");
102
+ const hasSource = projectRoomValues !== undefined
103
+ && Object.prototype.hasOwnProperty.call(projectRoomValues, "source");
84
104
  if (!projectRoomValues
85
105
  || requiredKeys.some((key) => typeof projectRoomValues[key] !== "string" || projectRoomValues[key].length === 0)
86
106
  || (hasResponsibility && projectRoomValues.responsibility !== undefined
87
107
  && (typeof projectRoomValues.responsibility !== "string" || projectRoomValues.responsibility.length === 0))
108
+ || (hasSource && projectRoomValues.source !== undefined
109
+ && (typeof projectRoomValues.source !== "string" || projectRoomValues.source.length === 0))
88
110
  || (projectRoomValues.role !== "coordinator" && projectRoomValues.role !== "specialist")) {
89
111
  throw new Error("Invalid trusted agent run context");
90
112
  }
@@ -103,6 +125,9 @@ export function parseTrustedRunContext(value: unknown): TrustedRunContext {
103
125
  if (hasResponsibility && typeof projectRoomValues.responsibility === "string") {
104
126
  parsedProjectRoom.responsibility = projectRoomValues.responsibility;
105
127
  }
128
+ if (hasSource && typeof projectRoomValues.source === "string") {
129
+ parsedProjectRoom.source = projectRoomValues.source;
130
+ }
106
131
  return { projectRoom: parsedProjectRoom };
107
132
  }
108
133
 
@@ -0,0 +1,43 @@
1
+ import { parseMcpUiRef, isMcpUiRef } from "../McpUiProtocol";
2
+
3
+ describe("parseMcpUiRef", () => {
4
+ it("parses a valid mcp ref", () => {
5
+ const ref = { kind: "mcp", serverKey: "weather", resourceUri: "ui://weather/dashboard", displayMode: "inline" };
6
+ expect(parseMcpUiRef(ref)).toEqual(ref);
7
+ });
8
+
9
+ it("defaults displayMode to inline", () => {
10
+ const ref = parseMcpUiRef({ kind: "mcp", serverKey: "a", resourceUri: "ui://a/x" });
11
+ expect(ref?.displayMode).toBe("inline");
12
+ });
13
+
14
+ it("preserves structuredContent on an mcp ref", () => {
15
+ const ref = parseMcpUiRef({
16
+ kind: "mcp",
17
+ serverKey: "weather",
18
+ resourceUri: "ui://weather/dashboard",
19
+ structuredContent: { a: 1 },
20
+ });
21
+ expect(ref).toEqual({
22
+ kind: "mcp",
23
+ serverKey: "weather",
24
+ resourceUri: "ui://weather/dashboard",
25
+ displayMode: "inline",
26
+ structuredContent: { a: 1 },
27
+ });
28
+ });
29
+
30
+ it("omits structuredContent when absent", () => {
31
+ const ref = parseMcpUiRef({ kind: "mcp", serverKey: "a", resourceUri: "ui://a/x" });
32
+ expect(ref && "structuredContent" in ref).toBe(false);
33
+ });
34
+
35
+ it("rejects unknown kind", () => {
36
+ expect(parseMcpUiRef({ kind: "nope" })).toBeNull();
37
+ });
38
+
39
+ it("isMcpUiRef narrows", () => {
40
+ expect(isMcpUiRef({ kind: "plugin", pluginType: "p", resource: "ui/x.html", displayMode: "inline" })).toBe(true);
41
+ expect(isMcpUiRef("x")).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,28 @@
1
+ import type { McpAppMenuConfig, MenuContentConfig, MenuContentType } from "../MenuProtocol";
2
+
3
+ describe("MenuProtocol mcpApp content type", () => {
4
+ it("round-trips both mcp and plugin McpAppMenuConfig variants", () => {
5
+ const mcp: McpAppMenuConfig = {
6
+ kind: "mcp",
7
+ serverKey: "srv",
8
+ resourceUri: "ui://srv/a.html",
9
+ displayMode: "inline",
10
+ title: "App",
11
+ toolInput: { area: "eu" },
12
+ };
13
+ const plugin: McpAppMenuConfig = {
14
+ kind: "plugin",
15
+ pluginType: "my-plugin",
16
+ resource: "ui/report.html",
17
+ title: "Report",
18
+ };
19
+ for (const cfg of [mcp, plugin] as MenuContentConfig[]) {
20
+ expect(JSON.parse(JSON.stringify(cfg))).toEqual(cfg);
21
+ }
22
+ });
23
+
24
+ it("includes file and mcpApp in MenuContentType", () => {
25
+ const types: MenuContentType[] = ["agent", "html", "custom", "file", "mcpApp"];
26
+ expect(new Set(types).size).toBe(5);
27
+ });
28
+ });
@@ -268,6 +268,10 @@ class FakeProjectRoomMessageStore implements ProjectRoomMessageStore {
268
268
  async countAfter(input: Parameters<ProjectRoomMessageStore["countAfter"]>[0]): Promise<number> {
269
269
  return input.tenantId === "tenant-1" && input.roomId === "room-1" ? 1 : 0;
270
270
  }
271
+
272
+ async deleteChatOlderThan(input: Parameters<ProjectRoomMessageStore["deleteChatOlderThan"]>[0]): Promise<number> {
273
+ return input.limit > 0 ? 1 : 0;
274
+ }
271
275
  }
272
276
 
273
277
  describe("ProjectRoomStore", () => {
@@ -363,5 +367,7 @@ describe("ProjectRoomMessageStore", () => {
363
367
  expect(await store.list({ tenantId: "tenant-1", roomId: "room-1", before: cursor, limit: 10 })).toEqual([message]);
364
368
  expect(await store.findById("tenant-1", "message-1")).toEqual(message);
365
369
  expect(await store.findById("tenant-1", "missing-message")).toBeNull();
370
+ expect(await store.deleteChatOlderThan({ before: message.createdAt, limit: 10 })).toBe(1);
371
+ expect(await store.deleteChatOlderThan({ before: message.createdAt, limit: 0 })).toBe(0);
366
372
  });
367
373
  });
@@ -0,0 +1,27 @@
1
+ import { normalizeTaskMetadataScalar } from "../TaskStoreProtocol";
2
+
3
+ describe("normalizeTaskMetadataScalar", () => {
4
+ it("returns strings unchanged", () => {
5
+ expect(normalizeTaskMetadataScalar("p1")).toBe("p1");
6
+ expect(normalizeTaskMetadataScalar("1")).toBe("1");
7
+ });
8
+
9
+ it("stringifies finite numbers and rejects non-finite ones", () => {
10
+ expect(normalizeTaskMetadataScalar(1)).toBe("1");
11
+ expect(normalizeTaskMetadataScalar(1.5)).toBe("1.5");
12
+ expect(normalizeTaskMetadataScalar(Number.NaN)).toBeUndefined();
13
+ expect(normalizeTaskMetadataScalar(Infinity)).toBeUndefined();
14
+ });
15
+
16
+ it("stringifies booleans as JSON text", () => {
17
+ expect(normalizeTaskMetadataScalar(true)).toBe("true");
18
+ expect(normalizeTaskMetadataScalar(false)).toBe("false");
19
+ });
20
+
21
+ it("rejects non-scalar values", () => {
22
+ expect(normalizeTaskMetadataScalar(null)).toBeUndefined();
23
+ expect(normalizeTaskMetadataScalar(undefined)).toBeUndefined();
24
+ expect(normalizeTaskMetadataScalar({ a: 1 })).toBeUndefined();
25
+ expect(normalizeTaskMetadataScalar([1])).toBeUndefined();
26
+ });
27
+ });
@@ -0,0 +1,46 @@
1
+ import type { MessageChunk, ToolCall } from "../MessageProtocol";
2
+
3
+ describe("ToolCall.ui", () => {
4
+ it("round-trips structuredContent, meta, and content blocks through JSON", () => {
5
+ const toolCall: ToolCall = {
6
+ id: "t1",
7
+ name: "get_widget",
8
+ args: {},
9
+ type: "tool_call",
10
+ response: "ok",
11
+ ui: {
12
+ structuredContent: { cpu: 42 },
13
+ meta: { source: "mock" },
14
+ contentBlocks: [
15
+ { type: "image", source_type: "base64", data: "AAAA", mime_type: "image/png" },
16
+ ],
17
+ },
18
+ };
19
+
20
+ const parsed = JSON.parse(JSON.stringify(toolCall)) as ToolCall;
21
+
22
+ expect(parsed.ui?.structuredContent).toEqual({ cpu: 42 });
23
+ expect(parsed.ui?.meta).toEqual({ source: "mock" });
24
+ expect(parsed.ui?.contentBlocks).toHaveLength(1);
25
+ });
26
+
27
+ it("has no ui property after a JSON round-trip of a plain tool call", () => {
28
+ const parsed = JSON.parse(
29
+ JSON.stringify({ id: "t2", name: "echo", args: {}, type: "tool_call" })
30
+ ) as ToolCall;
31
+ expect("ui" in parsed).toBe(false);
32
+ });
33
+
34
+ it("lets a tool chunk carry the raw LangChain artifact", () => {
35
+ const chunk: MessageChunk = {
36
+ type: "tool",
37
+ data: {
38
+ id: "t3",
39
+ tool_call_id: "c3",
40
+ content: "done",
41
+ artifact: [{ type: "mcp_structured_content", data: { a: 1 } }],
42
+ },
43
+ };
44
+ expect(Array.isArray(chunk.data.artifact)).toBe(true);
45
+ });
46
+ });