diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 386196d06a8..aea9b3fa5c8 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3701,7 +3701,7 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute workflow", - "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", + "description": "Run the active deployment, or set `run.source: \"manual\"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: \"failed\"` and `error.code: \"TIMEOUT\"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: \"ASYNC_ENQUEUE_AMBIGUOUS\"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.execute", "x-oauth-scope": "api:write", "tags": ["Workflows"], @@ -3891,7 +3891,7 @@ } }, "202": { - "description": "The asynchronous run was queued.", + "description": "The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -14267,7 +14267,7 @@ "required": ["data"], "additionalProperties": false, "title": "Queued workflow execution response", - "description": "Receipt returned for an asynchronous workflow run.", + "description": "Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.", "examples": [ { "data": { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts new file mode 100644 index 00000000000..ff78b3b6b0b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { createFieldFromOutput } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields' + +describe('createFieldFromOutput', () => { + it.each([ + ['untyped elements', { type: 'any' }], + ['primitive elements', { type: 'string' }], + ])('offers no child fields for an array of %s', (_, items) => { + const field = createFieldFromOutput('rows', { type: 'array', items }) + expect(field.children).toBeUndefined() + }) + + it('offers the declared fields of object elements', () => { + const field = createFieldFromOutput('rows', { + type: 'array', + items: { type: 'object', properties: { id: { type: 'string' } } }, + }) + expect(field.children?.map((child) => child.name)).toEqual(['id']) + }) + + it('keeps the fields of an untyped object that has a field named items', () => { + const field = createFieldFromOutput('page', { + items: { type: 'string' }, + total: { type: 'number' }, + }) + expect(field.children?.map((child) => child.name)).toEqual(['items', 'total']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts index f079448eea9..8a46bc6e735 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts @@ -12,7 +12,8 @@ const RESERVED_KEYS = new Set(['type', 'description']) /** * Checks if a property is an object type */ -const isObject = (prop: any): boolean => prop && typeof prop === 'object' +const isObject = (prop: unknown): prop is Record => + prop !== null && typeof prop === 'object' /** * Gets a subblock value from the store @@ -35,6 +36,11 @@ const extractChildFields = (prop: any): SchemaField[] | undefined => { return extractNestedFields(prop.items.properties) } + /** Typed elements without `properties` (strings, `any`) have no fields to reference. */ + if (prop.type === 'array' && isObject(prop.items) && typeof prop.items.type === 'string') { + return undefined + } + if (!('type' in prop)) { return extractNestedFields(prop) } @@ -71,7 +77,7 @@ const extractNestedFields = (properties: Record): SchemaField[] => /** * Creates a schema field from an output definition */ -const createFieldFromOutput = (name: string, output: any): SchemaField => { +export const createFieldFromOutput = (name: string, output: unknown): SchemaField => { const hasExplicitType = isObject(output) && typeof output.type === 'string' const type = hasExplicitType ? output.type : isObject(output) ? 'object' : 'string' diff --git a/apps/sim/executor/utils/block-reference.test.ts b/apps/sim/executor/utils/block-reference.test.ts index db3ee222df5..b8c2840f7d8 100644 --- a/apps/sim/executor/utils/block-reference.test.ts +++ b/apps/sim/executor/utils/block-reference.test.ts @@ -144,6 +144,33 @@ describe('resolveBlockReference', () => { expect(result).toEqual({ value: undefined, blockId: 'block-1' }) }) + it('rejects an undeclared field under a json field that declares its properties', () => { + const ctx = createContext({ + blockData: { 'block-1': { result: { summary: 'hello' } } }, + blockOutputSchemas: { + 'block-1': { result: { type: 'json', properties: { summary: { type: 'string' } } } }, + }, + }) + + expect(resolveBlockReference('start', ['result', 'summary'], ctx)?.value).toBe('hello') + expect(() => resolveBlockReference('start', ['result', 'typo'], ctx)).toThrow( + InvalidFieldError + ) + }) + + it('accepts an undeclared field under an open json field that declares its properties', () => { + const ctx = createContext({ + blockData: { 'block-1': { result: { summary: 'hello' } } }, + blockOutputSchemas: { + 'block-1': { + result: { type: 'json', properties: { summary: { type: 'string' } }, open: true }, + }, + }, + }) + + expect(resolveBlockReference('start', ['result', 'extra'], ctx)?.value).toBeUndefined() + }) + it('should resolve values nested under json field on executed block', () => { const ctx = createContext({ blockData: { 'block-1': { result: { summary: 'hello' } } }, diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 2c2ccca82c2..55aea4a31c8 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -15,6 +15,8 @@ interface SchemaNode { type?: string description?: string properties?: unknown + /** Fields beyond `properties` are allowed, so a path past them is not checked. */ + open?: boolean items?: unknown } @@ -72,15 +74,25 @@ function getProperties(schema: unknown): Record | undefined { : undefined } +/** A node declaring its fields but allowing others, so a field it does not name is not an error. */ +function isOpenSchemaNode(value: unknown): boolean { + return asSchemaNode(value)?.open === true +} + function lookupField(schema: unknown, fieldName: string): unknown | undefined { if (typeof schema !== 'object' || schema === null) return undefined const typed = schema as Record + const props = getProperties(schema) + + /** An open node's fields are its `properties`; its own keys are schema metadata, never fields. */ + if (isOpenSchemaNode(schema)) { + return props && fieldName in props ? props[fieldName] : undefined + } if (fieldName in typed) { return typed[fieldName] } - const props = getProperties(schema) if (props && fieldName in props) { return props[fieldName] } @@ -140,7 +152,7 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri if (arrayMatch) { const [, prop] = arrayMatch const fieldDef = lookupField(current, prop) - if (!fieldDef) return false + if (!fieldDef) return isOpenSchemaNode(current) if (isFileType(fieldDef)) { const nextPart = pathParts[i + 1] @@ -194,9 +206,10 @@ export function isPathInSchema(schema: OutputSchema | undefined, pathParts: stri current = itemField continue } + return isOpenSchemaNode(items) } - return false + return isOpenSchemaNode(current) } return true diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index e6bb6fe056f..b991553fb4b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -223,7 +223,7 @@ const executeQueuedResponseSchema = documentedSchema( v2ExecuteWorkflowQueuedResponseSchema, 'ExecuteWorkflowQueuedResponse', 'Queued workflow execution response', - 'Receipt returned for an asynchronous workflow run.', + 'Receipt returned for an asynchronous workflow run, or for a synchronous run still running when `waitTimeoutSeconds` elapsed.', [QUEUED_RUN_EXAMPLE] ) @@ -1194,7 +1194,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.execute, operationId: 'executeWorkflowV2', summary: 'Execute workflow', - description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, + description: `Run the active deployment, or set \`run.source: "manual"\` to run the saved draft. Deployed execution requires workspace read access and OAuth \`api:write\` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse \`async: true\` for a queue receipt, or \`stream: true\` for SSE. For a synchronous run with both flags false, send \`Accept: application/x-ndjson\` for heartbeat frames and a final frame containing \`data\`. With \`waitTimeoutSeconds\`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: \`202\` on a JSON response, or a closing \`detached\` frame on an NDJSON stream, which keeps its \`200\`. Timeouts return \`200\` with \`status: "failed"\` and \`error.code: "TIMEOUT"\`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. ${EXECUTE_OPTION_CONSTRAINTS}\n\nSupply \`X-Run-Id\` to prevent duplicate execution. Reuse returns \`409\`, without replaying results. A \`503\` with \`error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"\` omits \`Retry-After\` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.`, errors: [ 'BadRequest', 'Unauthorized', @@ -1218,7 +1218,8 @@ const declaredRoutes = [ additionalContentTypes: ['application/x-ndjson', 'text/event-stream'], }, 202: { - description: 'The asynchronous run was queued.', + description: + 'The asynchronous run was queued, or the synchronous run was still running when `waitTimeoutSeconds` elapsed and continues in the background.', headers: ['X-Run-Id', ...RATE_LIMIT_HEADERS], }, }, diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index f56b0e612f6..37f3dca65ef 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -1246,7 +1246,7 @@ export const V2_MCP_OPERATIONS = { contract: v2ExecuteWorkflowContract, summary: 'Execute workflow', description: - 'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', + 'Run the active deployment, or set `run.source: "manual"` to run the saved draft. Deployed execution requires workspace read access and OAuth `api:write` scope or an API key; public deployments also allow anonymous synchronous or streamed runs. Manual runs require personal-key or OAuth write access and cannot be async.\n\nUse `async: true` for a queue receipt, or `stream: true` for SSE. For a synchronous run with both flags false, send `Accept: application/x-ndjson` for heartbeat frames and a final frame containing `data`. With `waitTimeoutSeconds`, a synchronous run still going when the wait elapses continues in the background and answers with its run receipt instead: `202` on a JSON response, or a closing `detached` frame on an NDJSON stream, which keeps its `200`. Timeouts return `200` with `status: "failed"` and `error.code: "TIMEOUT"`; inspect run status. See [Streaming](/workflows/deployment/api#streaming) for event formats. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nSupply `X-Run-Id` to prevent duplicate execution. Reuse returns `409`, without replaying results. A `503` with `error.details.code: "ASYNC_ENQUEUE_AMBIGUOUS"` omits `Retry-After` because the run may already have started. Reconcile using the returned run ID; do not start another run with a new or omitted ID.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/workflows/[workflowId]/execute/route').then((route) => route.POST), }, diff --git a/apps/sim/lib/mcp/output-schema.ts b/apps/sim/lib/mcp/output-schema.ts index 397785d73c3..23c7e2c5249 100644 --- a/apps/sim/lib/mcp/output-schema.ts +++ b/apps/sim/lib/mcp/output-schema.ts @@ -1,4 +1,4 @@ -import { isRecordLike } from '@sim/utils/object' +import { isRecordLike, toRecord } from '@sim/utils/object' import type { McpToolSchema } from '@/lib/mcp/types' /** @@ -15,15 +15,21 @@ const MAX_SHAPE_DEPTH = 8 const MAX_SHAPE_NODES = 500 /** - * The structure of a declared output schema: types, properties and array items - * only. Descriptions, enums and examples are dropped because the snapshot is a - * block input, and the executor and lint read every string in a block's inputs - * as a possible `` reference. + * The structure of a declared output schema: types, properties, openness and + * array items only. Descriptions, enums and examples are dropped because the + * snapshot is a block input, and the executor and lint read every string in a + * block's inputs as a possible `` reference. */ export interface McpToolOutputShape { type: 'object' | 'array' | 'string' | 'number' | 'boolean' | 'any' - /** Declared fields of a closed object; absent when the object is open or undeclared. */ + /** Declared fields of an object; absent when it declares none, which leaves every field allowed. */ properties?: Record + /** + * The object allows fields beyond `properties`, as JSON Schema reads any object + * not declaring `additionalProperties: false`. A snapshot saved before this was + * recorded lacks it and stays closed until the operation is picked again. + */ + open?: true items?: McpToolOutputShape } @@ -67,23 +73,28 @@ function reduceNode(node: unknown, depth: number, budget: { nodes: number }): Mc switch (declaredType(node)) { case 'object': { - const open = - (node.additionalProperties !== undefined && node.additionalProperties !== false) || - node.patternProperties !== undefined - if (open || !isRecordLike(node.properties)) return { type: 'object' } + const closed = node.additionalProperties === false && node.patternProperties === undefined + if (!closed && !isRecordLike(node.properties)) return { type: 'object' } const entries: [string, McpToolOutputShape][] = [] - for (const [name, child] of Object.entries(node.properties)) { - /** Past the budget the object stays open: any field resolves, none is saved. */ + for (const [name, child] of Object.entries(toRecord(node.properties))) { + /** Past the budget the object is saved untyped: any field resolves, none is declared. */ if (budget.nodes <= 0) return { type: 'object' } entries.push([name, reduceNode(child, depth + 1, budget)]) } - /** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */ - return { type: 'object', properties: Object.fromEntries(entries) } + return { + type: 'object', + /** `fromEntries` keeps every declared name, `__proto__` included, as an own field. */ + properties: Object.fromEntries(entries), + ...(!closed && { open: true }), + } } case 'array': - return isRecordLike(node.items) - ? { type: 'array', items: reduceNode(node.items, depth + 1, budget) } - : { type: 'array' } + return { + type: 'array', + items: isRecordLike(node.items) + ? reduceNode(node.items, depth + 1, budget) + : { type: 'any' }, + } case 'string': return { type: 'string' } case 'number': @@ -139,6 +150,7 @@ function isShape(value: unknown, depth = 0): value is McpToolOutputShape { return false } if (depth > MAX_SHAPE_DEPTH + 1) return false + if (value.open !== undefined && value.open !== true) return false if (value.items !== undefined && !isShape(value.items, depth + 1)) return false if (value.properties === undefined) return true return ( diff --git a/apps/sim/lib/workflows/blocks/outputs.ts b/apps/sim/lib/workflows/blocks/outputs.ts index 5b9eee3a3a1..24a44465568 100644 --- a/apps/sim/lib/workflows/blocks/outputs.ts +++ b/apps/sim/lib/workflows/blocks/outputs.ts @@ -437,6 +437,8 @@ export function getEvaluatorMetricOutputs( interface DeclaredOutputNode { type: PrimitiveValueType properties?: Record + /** Fields beyond `properties` are allowed: a reference to one checks like any `json` path. */ + open?: true items?: DeclaredOutputNode } @@ -449,6 +451,7 @@ function toOutputNode(shape: McpToolOutputShape): DeclaredOutputNode { properties: Object.fromEntries( Object.entries(shape.properties).map(([name, child]) => [name, toOutputNode(child)]) ), + ...(shape.open && { open: true }), } : { type: 'json' } case 'array': diff --git a/apps/sim/lib/workflows/editing/dangling-refs.test.ts b/apps/sim/lib/workflows/editing/dangling-refs.test.ts index 5cec70c9f84..b7ca5986587 100644 --- a/apps/sim/lib/workflows/editing/dangling-refs.test.ts +++ b/apps/sim/lib/workflows/editing/dangling-refs.test.ts @@ -284,8 +284,13 @@ describe('collectDanglingBlockOutputReferences', () => { type: 'object' as const, properties: { role: { type: 'string' }, - person: { type: 'object', properties: { id: { type: 'string' } } }, + person: { + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: false, + }, }, + additionalProperties: false, } function mcpGraph( @@ -366,7 +371,11 @@ describe('collectDanglingBlockOutputReferences', () => { type: 'array', items: { type: 'array', - items: { type: 'object', properties: { id: { type: 'string' } } }, + items: { + type: 'object', + properties: { id: { type: 'string' } }, + additionalProperties: false, + }, }, }, }, @@ -382,6 +391,35 @@ describe('collectDanglingBlockOutputReferences', () => { expect(finding?.reason).toContain('Declared fields: structuredContent.rows.id') }) + it('accepts an undeclared field of an object the schema leaves open', () => { + const open = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { + role: { type: 'string' }, + person: { type: 'object', properties: { id: { type: 'string' } } }, + }, + additionalProperties: false, + }) + expect( + collectDanglingBlockOutputReferences( + mcpGraph('', open) + ) + ).toEqual([]) + }) + + it('accepts a path inside the elements of an array that declares no items', () => { + const untypedItems = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { rows: { type: 'array' } }, + additionalProperties: false, + }) + expect( + collectDanglingBlockOutputReferences( + mcpGraph('', untypedItems) + ) + ).toEqual([]) + }) + it('keeps flagging structuredContent when the operation declares no schema', () => { for (const snapshot of [ undefined, diff --git a/apps/sim/serializer/mcp-output-schema.test.ts b/apps/sim/serializer/mcp-output-schema.test.ts index 23f4585e01b..027a8cacdc3 100644 --- a/apps/sim/serializer/mcp-output-schema.test.ts +++ b/apps/sim/serializer/mcp-output-schema.test.ts @@ -82,8 +82,79 @@ describe('MCP block output typed from a declared output schema', () => { expect(resolve(declared, ['structuredContent', 'nickname'])).toBeUndefined() }) - it('fails a reference to a field the schema does not declare', () => { - expect(() => resolve(declared, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) + it('fails a reference to a field a closed schema does not declare', () => { + const closed = createMcpToolOutputSnapshot('server-1', 'on_call', { + ...ON_CALL_SCHEMA, + additionalProperties: false, + }) + expect(() => resolve(closed, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) + expect(resolve(closed, ['structuredContent', 'role'])).toBe('sales-ops') + }) + + it('resolves an undeclared field of an object that does not close itself to empty', () => { + expect(resolve(declared, ['structuredContent', 'onCall'])).toBeUndefined() + expect(resolve(declared, ['structuredContent', 'person', 'email'])).toBeUndefined() + }) + + it('keeps the declared fields of an explicitly open object and accepts others', () => { + const open = createMcpToolOutputSnapshot('server-1', 'on_call', { + ...ON_CALL_SCHEMA, + additionalProperties: { type: 'string' }, + }) + const schema = getBlockSchema(serializeMcpBlock(open)) + expect(schema?.structuredContent).toMatchObject({ + properties: { role: { type: 'string' }, person: { type: 'json' } }, + }) + expect(resolve(open, ['structuredContent', 'role'])).toBe('sales-ops') + expect(resolve(open, ['structuredContent', 'onCall'])).toBeUndefined() + }) + + it('resolves a path inside the elements of an array that declares no items', () => { + const untypedItems = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { rows: { type: 'array' } }, + additionalProperties: false, + }) + expect(resolve(untypedItems, ['structuredContent', 'rows', '0', 'id'])).toBeUndefined() + }) + + it('checks a declared field named like the openness flag as a field', () => { + const named = (additionalProperties?: false) => + createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + properties: { + open: { + type: 'object', + properties: { id: { type: 'string' } }, + ...(additionalProperties === false && { additionalProperties }), + }, + }, + ...(additionalProperties === false && { additionalProperties }), + }) + expect(resolve(named(), ['structuredContent', 'open', 'id'])).toBeUndefined() + expect(resolve(named(), ['structuredContent', 'open', 'extra'])).toBeUndefined() + expect(resolve(named(false), ['structuredContent', 'open', 'id'])).toBeUndefined() + expect(() => resolve(named(false), ['structuredContent', 'open', 'extra'])).toThrow( + InvalidFieldError + ) + }) + + it('rejects any field of a closed object that declares none', () => { + const empty = createMcpToolOutputSnapshot('server-1', 'on_call', { + type: 'object', + additionalProperties: false, + }) + expect(() => resolve(empty, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) + }) + + it('keeps a snapshot saved before openness was recorded closed until it is retaken', () => { + const legacy = { + server: 'server-1', + tool: 'on_call', + schema: { type: 'object', properties: { role: { type: 'string' } } }, + } + expect(resolve(legacy, ['structuredContent', 'role'])).toBe('sales-ops') + expect(() => resolve(legacy, ['structuredContent', 'onCall'])).toThrow(InvalidFieldError) }) it('keeps content as declared alongside the typed output', () => { diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index b8b4e30d6ec..ca374a5b8da 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -63,7 +63,7 @@ "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/types.ts": 2, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils.ts": 2, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx": 1, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts": 7, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-output-fields.ts": 6, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts": 1, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-scroll-management.ts": 1, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts": 1,