@decocms/mesh-sdk 1.2.1 → 1.2.3

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.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Usage utilities for extracting cost and token stats from AI provider metadata.
3
+ *
4
+ * Supports provider-specific cost extraction (e.g., OpenRouter)
5
+ * and aggregation of usage across messages or streaming steps.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ export interface UsageData {
13
+ inputTokens?: number;
14
+ outputTokens?: number;
15
+ reasoningTokens?: number;
16
+ totalTokens?: number;
17
+ /**
18
+ * AI SDK normalizes cache token counts across providers via
19
+ * usage.inputTokenDetails — populated identically by the anthropic,
20
+ * openai, google and openrouter adapters. `cachedInputTokens` is the
21
+ * convenience shorthand the AI SDK also surfaces (= cacheReadTokens).
22
+ */
23
+ cachedInputTokens?: number;
24
+ inputTokenDetails?: {
25
+ cacheReadTokens?: number;
26
+ cacheWriteTokens?: number;
27
+ noCacheTokens?: number;
28
+ };
29
+ providerMetadata?: {
30
+ [key: string]: unknown;
31
+ };
32
+ }
33
+
34
+ export interface UsageStats {
35
+ inputTokens: number;
36
+ outputTokens: number;
37
+ reasoningTokens: number;
38
+ totalTokens: number;
39
+ cost: number;
40
+ /** Tokens read from prompt cache (anthropic / openrouter / openai / google). */
41
+ cacheReadTokens: number;
42
+ /** Tokens written to prompt cache (Anthropic only — others auto-cache without separate billing). */
43
+ cacheWriteTokens: number;
44
+ }
45
+
46
+ type ProviderCostExtractor = (
47
+ providerMetadata: NonNullable<UsageData["providerMetadata"]>,
48
+ ) => number | null;
49
+
50
+ // ============================================================================
51
+ // Provider-specific cost extractors
52
+ // ============================================================================
53
+
54
+ /**
55
+ * Registry of provider-specific cost extractors.
56
+ * Each extractor attempts to get the cost from provider metadata.
57
+ */
58
+ const PROVIDER_COST_EXTRACTORS: Record<string, ProviderCostExtractor> = {
59
+ openrouter: (providerMetadata) => {
60
+ const openrouter = providerMetadata?.openrouter;
61
+ if (
62
+ typeof openrouter === "object" &&
63
+ openrouter !== null &&
64
+ "usage" in openrouter &&
65
+ typeof openrouter.usage === "object" &&
66
+ openrouter.usage !== null &&
67
+ "cost" in openrouter.usage &&
68
+ typeof openrouter.usage.cost === "number"
69
+ ) {
70
+ return openrouter.usage.cost;
71
+ }
72
+ return null;
73
+ },
74
+ };
75
+
76
+ // ============================================================================
77
+ // Cost extraction
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Extract cost from usage metadata by checking all known provider formats.
82
+ */
83
+ export function getCostFromUsage(usage: UsageData | null | undefined): number {
84
+ if (!usage?.providerMetadata) {
85
+ return 0;
86
+ }
87
+
88
+ for (const extractor of Object.values(PROVIDER_COST_EXTRACTORS)) {
89
+ const cost = extractor(usage.providerMetadata);
90
+ if (cost !== null) {
91
+ return cost;
92
+ }
93
+ }
94
+
95
+ return 0;
96
+ }
97
+
98
+ // ============================================================================
99
+ // Provider metadata sanitization
100
+ // ============================================================================
101
+
102
+ const ALLOWED_PROVIDER_FIELDS = ["usage", "cost", "model"] as const;
103
+
104
+ /**
105
+ * Sanitize provider metadata to prevent leaking sensitive data.
106
+ * Only allows whitelisted fields: usage, cost, model.
107
+ */
108
+ export function sanitizeProviderMetadata(
109
+ metadata: Record<string, unknown> | undefined,
110
+ ): Record<string, unknown> | undefined {
111
+ if (!metadata) return undefined;
112
+
113
+ const sanitized: Record<string, unknown> = {};
114
+ for (const provider in metadata) {
115
+ const providerData = metadata[provider];
116
+ if (typeof providerData === "object" && providerData !== null) {
117
+ const safeData: Record<string, unknown> = {};
118
+ for (const field of ALLOWED_PROVIDER_FIELDS) {
119
+ if (field in providerData) {
120
+ safeData[field] = (providerData as Record<string, unknown>)[field];
121
+ }
122
+ }
123
+ sanitized[provider] = safeData;
124
+ }
125
+ }
126
+ return Object.keys(sanitized).length > 0 ? sanitized : undefined;
127
+ }
128
+
129
+ // ============================================================================
130
+ // Usage accumulation
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Create an empty UsageStats object.
135
+ */
136
+ export function emptyUsageStats(): UsageStats {
137
+ return {
138
+ inputTokens: 0,
139
+ outputTokens: 0,
140
+ reasoningTokens: 0,
141
+ totalTokens: 0,
142
+ cost: 0,
143
+ cacheReadTokens: 0,
144
+ cacheWriteTokens: 0,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Accumulate a step's usage into an existing UsageStats total.
150
+ * Returns a new UsageStats object (immutable).
151
+ */
152
+ export function addUsage(
153
+ accumulated: UsageStats,
154
+ stepUsage: UsageData | null | undefined,
155
+ ): UsageStats {
156
+ if (!stepUsage) return accumulated;
157
+
158
+ const cacheRead =
159
+ stepUsage.inputTokenDetails?.cacheReadTokens ??
160
+ stepUsage.cachedInputTokens ??
161
+ 0;
162
+ const cacheWrite = stepUsage.inputTokenDetails?.cacheWriteTokens ?? 0;
163
+
164
+ return {
165
+ inputTokens: accumulated.inputTokens + (stepUsage.inputTokens ?? 0),
166
+ outputTokens: accumulated.outputTokens + (stepUsage.outputTokens ?? 0),
167
+ reasoningTokens:
168
+ accumulated.reasoningTokens + (stepUsage.reasoningTokens ?? 0),
169
+ totalTokens: accumulated.totalTokens + (stepUsage.totalTokens ?? 0),
170
+ cost: accumulated.cost + getCostFromUsage(stepUsage),
171
+ cacheReadTokens: accumulated.cacheReadTokens + cacheRead,
172
+ cacheWriteTokens: accumulated.cacheWriteTokens + cacheWrite,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Calculate aggregated usage stats from an array of messages.
178
+ * Each message is expected to have an optional `metadata.usage` field.
179
+ */
180
+ export function calculateUsageStats(
181
+ messages: Array<{ metadata?: { usage?: UsageData } }>,
182
+ ): UsageStats {
183
+ return messages.reduce<UsageStats>(
184
+ (acc, message) => addUsage(acc, message.metadata?.usage),
185
+ emptyUsageStats(),
186
+ );
187
+ }
@@ -0,0 +1,15 @@
1
+ // Plugin context provider and hook
2
+ export {
3
+ PluginContextProvider,
4
+ usePluginContext,
5
+ type PluginContextProviderProps,
6
+ type UsePluginContextOptions,
7
+ } from "./plugin-context-provider";
8
+
9
+ // Topbar portal system
10
+ export {
11
+ TopbarPortal,
12
+ TopbarPortalProvider,
13
+ useTopbarPortalTargets,
14
+ type TopbarSide,
15
+ } from "./topbar-portal";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Plugin Context Provider
3
+ *
4
+ * React context provider and hook for accessing plugin context.
5
+ * Moved from @decocms/bindings to @decocms/mesh-sdk to consolidate
6
+ * all plugin-facing React components in one package.
7
+ */
8
+
9
+ import { createContext, useContext, type ReactNode } from "react";
10
+ import type {
11
+ Binder,
12
+ PluginContext,
13
+ PluginContextPartial,
14
+ } from "@decocms/bindings";
15
+
16
+ // Internal context stores the partial version (nullable connection fields)
17
+ // The hook return type depends on the options passed
18
+ const PluginContextInternal = createContext<PluginContextPartial | null>(null);
19
+
20
+ export interface PluginContextProviderProps<TBinding extends Binder> {
21
+ value: PluginContext<TBinding> | PluginContextPartial<TBinding>;
22
+ children: ReactNode;
23
+ }
24
+
25
+ /**
26
+ * Provider component for plugin context.
27
+ * Used by the mesh app layout to provide context to plugin routes.
28
+ */
29
+ export function PluginContextProvider<TBinding extends Binder>({
30
+ value,
31
+ children,
32
+ }: PluginContextProviderProps<TBinding>) {
33
+ return (
34
+ <PluginContextInternal.Provider value={value as PluginContextPartial}>
35
+ {children}
36
+ </PluginContextInternal.Provider>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Options for usePluginContext hook.
42
+ */
43
+ export interface UsePluginContextOptions {
44
+ /**
45
+ * Set to true when calling from an empty state component.
46
+ * This returns nullable connection fields since no valid connection exists.
47
+ */
48
+ partial?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Hook to access the plugin context with typed tool caller.
53
+ *
54
+ * @template TBinding - The binding type for typed tool calls
55
+ * @param options - Optional settings
56
+ * @param options.partial - Set to true in empty state components where connection may not exist
57
+ * @throws Error if used outside of PluginContextProvider
58
+ * @throws Error if connection is null but partial option is not set
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * // In route component (connection guaranteed by layout)
63
+ * const { toolCaller, connection } = usePluginContext<typeof REGISTRY_APP_BINDING>();
64
+ * const result = await toolCaller("COLLECTION_REGISTRY_APP_LIST", { limit: 20 });
65
+ *
66
+ * // In empty state component (no connection available)
67
+ * const { session, org } = usePluginContext<typeof REGISTRY_APP_BINDING>({ partial: true });
68
+ * ```
69
+ */
70
+ export function usePluginContext<TBinding extends Binder = Binder>(options: {
71
+ partial: true;
72
+ }): PluginContextPartial<TBinding>;
73
+ export function usePluginContext<
74
+ TBinding extends Binder = Binder,
75
+ >(): PluginContext<TBinding>;
76
+ export function usePluginContext<TBinding extends Binder = Binder>(
77
+ options?: UsePluginContextOptions,
78
+ ): PluginContext<TBinding> | PluginContextPartial<TBinding> {
79
+ const context = useContext(PluginContextInternal);
80
+ if (!context) {
81
+ throw new Error(
82
+ "usePluginContext must be used within a PluginContextProvider",
83
+ );
84
+ }
85
+
86
+ // If partial mode, return as-is with nullable fields
87
+ if (options?.partial) {
88
+ return context as PluginContextPartial<TBinding>;
89
+ }
90
+
91
+ // Otherwise, assert that connection exists (routes should always have one)
92
+ if (!context.connectionId || !context.connection || !context.toolCaller) {
93
+ throw new Error(
94
+ "usePluginContext requires a valid connection. Use { partial: true } in empty state components.",
95
+ );
96
+ }
97
+
98
+ return context as PluginContext<TBinding>;
99
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Topbar Portal
3
+ *
4
+ * A React portal-based system for rendering content into the project topbar
5
+ * from anywhere in the component tree (including plugin routes).
6
+ *
7
+ * Uses createPortal so that portaled content preserves the source tree's
8
+ * React context -- plugin context, query client, etc. all work naturally.
9
+ *
10
+ * Usage:
11
+ *
12
+ * 1. The app wraps its layout with <TopbarPortalProvider>
13
+ * 2. ProjectTopbar calls useTopbarPortalTargets() and attaches callback refs to slot divs
14
+ * 3. Plugin components render <TopbarPortal side="right">...</TopbarPortal>
15
+ */
16
+
17
+ import { createContext, useContext, useState, type ReactNode } from "react";
18
+ import { createPortal } from "react-dom";
19
+
20
+ export type TopbarSide = "left" | "center" | "right";
21
+
22
+ interface TopbarPortalContextValue {
23
+ /** Current DOM elements for each slot (null until ProjectTopbar mounts) */
24
+ leftEl: HTMLDivElement | null;
25
+ centerEl: HTMLDivElement | null;
26
+ rightEl: HTMLDivElement | null;
27
+ /** Callback refs for ProjectTopbar to register slot elements */
28
+ setLeftEl: (el: HTMLDivElement | null) => void;
29
+ setCenterEl: (el: HTMLDivElement | null) => void;
30
+ setRightEl: (el: HTMLDivElement | null) => void;
31
+ }
32
+
33
+ const TopbarPortalContext = createContext<TopbarPortalContextValue | null>(
34
+ null,
35
+ );
36
+
37
+ /**
38
+ * Provider that manages the portal target DOM elements for the three topbar slots.
39
+ * Place this in the layout tree so it wraps both the topbar and the content area.
40
+ */
41
+ export function TopbarPortalProvider({ children }: { children: ReactNode }) {
42
+ const [leftEl, setLeftEl] = useState<HTMLDivElement | null>(null);
43
+ const [centerEl, setCenterEl] = useState<HTMLDivElement | null>(null);
44
+ const [rightEl, setRightEl] = useState<HTMLDivElement | null>(null);
45
+
46
+ return (
47
+ <TopbarPortalContext.Provider
48
+ value={{ leftEl, centerEl, rightEl, setLeftEl, setCenterEl, setRightEl }}
49
+ >
50
+ {children}
51
+ </TopbarPortalContext.Provider>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Hook used by the ProjectTopbar component to get callback refs for the portal target divs.
57
+ * Returns stable callback refs that register/unregister the DOM elements in the provider.
58
+ */
59
+ export function useTopbarPortalTargets() {
60
+ const ctx = useContext(TopbarPortalContext);
61
+
62
+ const leftRef = (el: HTMLDivElement | null) => ctx?.setLeftEl(el);
63
+ const centerRef = (el: HTMLDivElement | null) => ctx?.setCenterEl(el);
64
+ const rightRef = (el: HTMLDivElement | null) => ctx?.setRightEl(el);
65
+
66
+ if (!ctx) return null;
67
+
68
+ return { leftRef, centerRef, rightRef };
69
+ }
70
+
71
+ /**
72
+ * Portal component that renders children into one of the topbar slots.
73
+ *
74
+ * Because this uses React createPortal, the children maintain the React context
75
+ * of the component that renders <TopbarPortal> -- not the topbar's context.
76
+ * This means plugin context (connection, toolCaller, etc.) is available.
77
+ *
78
+ * @example
79
+ * ```tsx
80
+ * import { TopbarPortal, usePluginContext } from "@decocms/mesh-sdk/plugins";
81
+ *
82
+ * function MyPluginPage() {
83
+ * const { toolCaller } = usePluginContext<typeof MY_BINDING>();
84
+ *
85
+ * return (
86
+ * <>
87
+ * <TopbarPortal side="right">
88
+ * <Button onClick={() => toolCaller("SOME_TOOL", {})}>
89
+ * Action
90
+ * </Button>
91
+ * </TopbarPortal>
92
+ * <div>Page content...</div>
93
+ * </>
94
+ * );
95
+ * }
96
+ * ```
97
+ */
98
+ export function TopbarPortal({
99
+ side,
100
+ children,
101
+ }: {
102
+ side: TopbarSide;
103
+ children: ReactNode;
104
+ }) {
105
+ const ctx = useContext(TopbarPortalContext);
106
+ if (!ctx) return null;
107
+
108
+ const el =
109
+ side === "left"
110
+ ? ctx.leftEl
111
+ : side === "center"
112
+ ? ctx.centerEl
113
+ : ctx.rightEl;
114
+
115
+ if (!el) return null;
116
+
117
+ return createPortal(children, el);
118
+ }
@@ -0,0 +1,86 @@
1
+ // ============================================================================
2
+ // AI Provider Types — shared between server tool output and client hooks
3
+ // ============================================================================
4
+
5
+ export const PROVIDER_IDS = [
6
+ "deco",
7
+ "anthropic",
8
+ "openrouter",
9
+ "google",
10
+ "claude-code",
11
+ "codex",
12
+ "openai-compatible",
13
+ ] as const;
14
+
15
+ export type ProviderId = (typeof PROVIDER_IDS)[number];
16
+
17
+ /** All known model capability tokens. Sourced from OpenRouter modality strings. */
18
+ export const MODEL_CAPABILITIES = [
19
+ "text",
20
+ "image",
21
+ "vision",
22
+ "audio",
23
+ "video",
24
+ "file",
25
+ "reasoning",
26
+ ] as const;
27
+
28
+ export type ModelCapability = (typeof MODEL_CAPABILITIES)[number];
29
+
30
+ export interface AiProviderModelLimits {
31
+ contextWindow: number;
32
+ /** Null means the provider does not advertise a specific cap. */
33
+ maxOutputTokens: number | null;
34
+ }
35
+
36
+ export interface AiProviderModelCosts {
37
+ input: number;
38
+ output: number;
39
+ }
40
+
41
+ export interface AiProviderModel {
42
+ providerId: ProviderId;
43
+ modelId: string;
44
+ title: string;
45
+ description: string | null;
46
+ logo: string | null;
47
+ capabilities: ModelCapability[];
48
+ limits: AiProviderModelLimits | null;
49
+ costs: AiProviderModelCosts | null;
50
+ /** When true the upstream provider has flagged this model as deprecated. */
51
+ deprecated?: boolean;
52
+ /**
53
+ * When true, this model can ONLY be used through the provider's
54
+ * `AsyncResearchProvider` capability (e.g. Gemini Deep Research via the
55
+ * Interactions API). It is unusable as a Thinking/Coding/Fast/Image model
56
+ * because `streamText` / `generateContent` will reject it. UIs should
57
+ * restrict it to the deep-research slot.
58
+ */
59
+ asyncResearch?: boolean;
60
+ /** Client-side only — the credential key ID used to fetch this model. */
61
+ keyId?: string;
62
+ }
63
+
64
+ export interface AiProviderKey {
65
+ id: string;
66
+ providerId: ProviderId;
67
+ label: string;
68
+ /**
69
+ * Frontend preset id (e.g. "litellm", "ollama") for openai-compatible keys
70
+ * that were created from a branded preset card. Null otherwise.
71
+ */
72
+ presetId: string | null;
73
+ createdBy: string;
74
+ createdAt: string;
75
+ }
76
+
77
+ export interface AiProviderInfo {
78
+ id: ProviderId;
79
+ name: string;
80
+ description: string;
81
+ logo?: string | null;
82
+ supportedMethods: ("api-key" | "oauth-pkce" | "cli-activate")[];
83
+ supportsTopUp?: boolean;
84
+ supportsCredits?: boolean;
85
+ supportsProvision?: boolean;
86
+ }
@@ -5,6 +5,7 @@
5
5
  * Uses snake_case field names matching the database schema directly.
6
6
  */
7
7
 
8
+ import { ToolSchema } from "@modelcontextprotocol/sdk/types.js";
8
9
  import { z } from "zod";
9
10
 
10
11
  /**
@@ -23,13 +24,26 @@ const OAuthConfigSchema = z.object({
23
24
  export type OAuthConfig = z.infer<typeof OAuthConfigSchema>;
24
25
 
25
26
  /**
26
- * Tool definition schema from MCP discovery
27
+ * JSON-Schema-safe JSON Schema object.
28
+ *
29
+ * The MCP SDK's ToolSchema uses z.custom() (AssertObjectSchema) for property
30
+ * values inside inputSchema/outputSchema. Zod 4's toJSONSchema() cannot
31
+ * represent z.custom(), throwing "Custom types cannot be represented in JSON
32
+ * Schema". We override only these two fields with a safe equivalent so that
33
+ * all other ToolSchema fields (name, annotations, execution, icons, _meta, …)
34
+ * still flow through automatically when the MCP SDK is updated.
27
35
  */
28
- const ToolDefinitionSchema = z.object({
29
- name: z.string(),
30
- description: z.string().optional(),
31
- inputSchema: z.record(z.string(), z.unknown()),
32
- outputSchema: z.record(z.string(), z.unknown()).optional(),
36
+ const JsonSchemaObjectSchema = z
37
+ .object({
38
+ type: z.literal("object"),
39
+ properties: z.record(z.string(), z.unknown()).optional(),
40
+ required: z.array(z.string()).optional(),
41
+ })
42
+ .catchall(z.unknown());
43
+
44
+ const ToolDefinitionSchema = ToolSchema.extend({
45
+ inputSchema: JsonSchemaObjectSchema,
46
+ outputSchema: JsonSchemaObjectSchema.optional(),
33
47
  });
34
48
 
35
49
  export type ToolDefinition = z.infer<typeof ToolDefinitionSchema>;
@@ -88,6 +102,11 @@ export const ConnectionEntitySchema = z.object({
88
102
  icon: z.string().nullable().describe("Icon URL for the connection"),
89
103
  app_name: z.string().nullable().describe("Associated app name"),
90
104
  app_id: z.string().nullable().describe("Associated app ID"),
105
+ slug: z
106
+ .string()
107
+ .nullable()
108
+ .optional()
109
+ .describe("URL-safe slug derived from app_name, connection_url, or title"),
91
110
 
92
111
  connection_type: z
93
112
  .enum(["HTTP", "SSE", "Websocket", "STDIO", "VIRTUAL"])
@@ -152,20 +171,24 @@ export const ConnectionCreateDataSchema = ConnectionEntitySchema.omit({
152
171
  tools: true,
153
172
  bindings: true,
154
173
  status: true,
155
- }).partial({
156
- id: true,
157
- description: true,
158
- icon: true,
159
- app_name: true,
160
- app_id: true,
161
- connection_url: true,
162
- connection_token: true,
163
- connection_headers: true,
164
- oauth_config: true,
165
- configuration_state: true,
166
- configuration_scopes: true,
167
- metadata: true,
168
- });
174
+ })
175
+ .partial({
176
+ id: true,
177
+ description: true,
178
+ icon: true,
179
+ app_name: true,
180
+ app_id: true,
181
+ connection_url: true,
182
+ connection_token: true,
183
+ connection_headers: true,
184
+ oauth_config: true,
185
+ configuration_state: true,
186
+ configuration_scopes: true,
187
+ metadata: true,
188
+ })
189
+ .extend({
190
+ icon: z.string().nullish(),
191
+ });
169
192
 
170
193
  export type ConnectionCreateData = z.infer<typeof ConnectionCreateDataSchema>;
171
194
 
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createDecopilotThreadStatusEvent,
4
+ DECOPILOT_EVENTS,
5
+ } from "./decopilot-events";
6
+
7
+ describe("createDecopilotThreadStatusEvent", () => {
8
+ test("carries virtualMcpId, createdBy, and triggerId on data", () => {
9
+ const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
10
+ virtualMcpId: "vm-1",
11
+ createdBy: "user-1",
12
+ triggerId: "trig-1",
13
+ });
14
+ expect(e.type).toBe(DECOPILOT_EVENTS.THREAD_STATUS);
15
+ expect(e.subject).toBe("task-1");
16
+ expect(e.data.status).toBe("in_progress");
17
+ expect(e.data.virtual_mcp_id).toBe("vm-1");
18
+ expect(e.data.created_by).toBe("user-1");
19
+ expect(e.data.trigger_id).toBe("trig-1");
20
+ });
21
+
22
+ test("omits optional fields when not provided", () => {
23
+ const e = createDecopilotThreadStatusEvent("task-1", "completed");
24
+ expect(e.data.status).toBe("completed");
25
+ expect(e.data.virtual_mcp_id).toBeUndefined();
26
+ expect(e.data.created_by).toBeUndefined();
27
+ expect(e.data.trigger_id).toBeUndefined();
28
+ });
29
+
30
+ test("preserves explicit null trigger_id (human-initiated thread)", () => {
31
+ const e = createDecopilotThreadStatusEvent("task-1", "completed", {
32
+ triggerId: null,
33
+ });
34
+ expect(e.data.trigger_id).toBeNull();
35
+ });
36
+
37
+ test("works with only virtualMcpId provided (migration shape)", () => {
38
+ const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
39
+ virtualMcpId: "vm-1",
40
+ });
41
+ expect(e.data.virtual_mcp_id).toBe("vm-1");
42
+ expect(e.data.created_by).toBeUndefined();
43
+ expect(e.data.trigger_id).toBeUndefined();
44
+ });
45
+ });
46
+
47
+ describe("createDecopilotThreadStatusEvent — enriched fields", () => {
48
+ test("round-trips title, branch, createdAt, updatedAt", () => {
49
+ const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
50
+ virtualMcpId: "vm-1",
51
+ title: "Refactor login",
52
+ branch: "feature/login",
53
+ createdAt: "2026-05-19T00:00:00.000Z",
54
+ updatedAt: "2026-05-19T00:05:00.000Z",
55
+ });
56
+ expect(e.data.title).toBe("Refactor login");
57
+ expect(e.data.branch).toBe("feature/login");
58
+ expect(e.data.created_at).toBe("2026-05-19T00:00:00.000Z");
59
+ expect(e.data.updated_at).toBe("2026-05-19T00:05:00.000Z");
60
+ });
61
+
62
+ test("omits the new fields when not provided", () => {
63
+ const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
64
+ virtualMcpId: "vm-1",
65
+ });
66
+ expect(e.data.title).toBeUndefined();
67
+ expect(e.data.branch).toBeUndefined();
68
+ expect(e.data.created_at).toBeUndefined();
69
+ expect(e.data.updated_at).toBeUndefined();
70
+ });
71
+
72
+ test("explicit null branch is preserved", () => {
73
+ const e = createDecopilotThreadStatusEvent("task-1", "in_progress", {
74
+ branch: null,
75
+ });
76
+ expect(e.data.branch).toBeNull();
77
+ });
78
+ });