From 922922e4ecd6ade8cc6f92fcec7a2faf1f23814c Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 07:06:09 -0600 Subject: [PATCH 01/11] feat(a365): add typed execute tool schemas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- .github/workflows/pr-validation.yml | 3 + A365_DOCUMENTATION.md | 67 +++++- CHANGELOG.md | 3 + package.json | 1 + src/a365/contracts.ts | 24 ++- src/a365/index.ts | 14 ++ src/a365/message-utils.ts | 36 +++- src/a365/scopes/ExecuteToolScope.ts | 17 +- src/a365/tool-call-models.ts | 182 +++++++++++++++++ src/index.ts | 14 ++ .../unit/a365/executeToolJsonModels.test.ts | 166 +++++++++++++++ test/internal/unit/a365/messageUtils.test.ts | 92 ++++++++- test/internal/unit/a365/scopes.test.ts | 192 ++++++++++++++++++ tsconfig.type-tests.json | 7 + 14 files changed, 809 insertions(+), 9 deletions(-) create mode 100644 src/a365/tool-call-models.ts create mode 100644 test/internal/unit/a365/executeToolJsonModels.test.ts create mode 100644 tsconfig.type-tests.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 32a41d56..1ff57a4a 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -27,6 +27,9 @@ jobs: - name: Build run: npm run build + - name: Type-check test sources + run: npm run typecheck:test + - name: Format check run: npm run format diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 7b041e07..64479e2a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -17,10 +17,15 @@ Use scopes when you want explicit spans for agent, tool, inference, or output wo ```typescript import { + ExecuteToolCallArguments, + ExecuteToolCallResult, ExecuteToolScope, InferenceOperationType, InferenceScope, InvokeAgentScope, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, } from "@microsoft/opentelemetry"; const invokeScope = InvokeAgentScope.start( @@ -30,9 +35,36 @@ const invokeScope = InvokeAgentScope.start( ); invokeScope.run(async () => { + const toolArguments = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + resources: [ + { + id: "drive-item-1", + uri: "https://contoso.example/items/1", + name: "Quarterly plan", + type: "document", + provider: "sharepoint", + identifiers: [{ type: "driveItem", value: "1" }], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + }, + custom_resource_field: "kept", + }, + ], + parameters: { query: "hello", includeArchived: false }, + custom_argument_field: "kept", + }); + const toolScope = ExecuteToolScope.start( { conversationId: "conv-123" }, - { toolName: "Search", input: { query: "hello" } }, + { + toolName: "Search", + arguments: toolArguments, + toolCallId: "tool-call-123", + toolType: "function", + }, { agentId: "agent-1", tenantId: "tenant-1" }, ); @@ -42,6 +74,36 @@ invokeScope.run(async () => { { agentId: "agent-1", tenantId: "tenant-1" }, ); + toolScope.recordResponse( + new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + code: "200", + message: "Completed", + }, + resources: [ + { + id: "drive-item-1", + name: "Quarterly plan", + type: "document", + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + code: "200", + }, + policy: { + decision: ToolPolicyDecision.ALLOW, + id: "policy-1", + name: "AllowDocumentRead", + }, + data: { snippetCount: 3 }, + custom_result_field: "kept", + }, + ], + pagination: { has_more: false, total_count: 1 }, + custom_result_field: "kept", + }), + ); + toolScope.dispose(); inferenceScope.dispose(); }); @@ -49,6 +111,9 @@ invokeScope.run(async () => { invokeScope.dispose(); ``` +`ExecuteToolScope` serializes arguments to `gen_ai.tool.call.arguments` and results to +`gen_ai.tool.call.result` as JSON span attributes, so they may contain sensitive data. + ## Baggage And Context Use `BaggageBuilder` when you want tenant, agent, user, conversation, or session data to flow with the active context. diff --git a/CHANGELOG.md b/CHANGELOG.md index d61c5f77..2411433c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ### Other Changes - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. +### Features Added +- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". + ## [1.4.0] - 2026-09-08 ### Features Added diff --git a/package.json b/package.json index f6edea07..60a85055 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "test:unit": "vitest run --config vitest.unit.config.ts", "test:functional": "vitest run --config vitest.functional.config.ts", "test:integration": "node --test test/integration/*.test.mjs", + "typecheck:test": "tsc -p tsconfig.type-tests.json --noEmit", "test:performance": "node --expose-gc perf/benchmark.mjs", "test:esm-build": "node --input-type=module -e \"import('./dist/esm/distro/instrumentations.js').then(()=>console.log('esm import ok')).catch((e)=>{console.error(e);process.exit(1);})\"", "test:watch": "vitest", diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index 39bcba91..6c67ffd0 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -9,6 +9,26 @@ */ import type { SpanKind, TimeInput, Link, Context, TraceState } from "@opentelemetry/api"; +import type { ExecuteToolCallArguments } from "./tool-call-models.js"; + +export { + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, + ExecuteToolCallArguments, + ExecuteToolCallResult, +} from "./tool-call-models.js"; +export type { + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, +} from "./tool-call-models.js"; // --------------------------------------------------------------------------- // Default finish reason (per OTel spec) @@ -373,8 +393,8 @@ export interface InvokeAgentScopeDetails { export interface ToolCallDetails { /** Name of the tool being called (required). */ toolName: string; - /** Arguments passed to the tool, as an object or serialized string. */ - arguments?: Record | string; + /** Arguments passed to the tool, as an object, execute-tool schema model, or serialized string. */ + arguments?: Record | ExecuteToolCallArguments | string; /** Unique identifier of the tool call. */ toolCallId?: string; /** Human-readable description of the tool. */ diff --git a/src/a365/index.ts b/src/a365/index.ts index 9ae02297..f671efdb 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -38,6 +38,11 @@ export { InvocationRole, InferenceOperationType, DEFAULT_FINISH_REASON, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, + ExecuteToolCallArguments, + ExecuteToolCallResult, GuardrailDecisionType, GuardrailRiskSeverity, GuardrailTargetType, @@ -55,6 +60,15 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, AgentDetails, UserDetails, CallerDetails, diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts index 0b76af69..1263337b 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -15,7 +15,21 @@ import type { InputMessagesParam, OutputMessagesParam, } from "./contracts.js"; -import { MessageRole, DEFAULT_FINISH_REASON } from "./contracts.js"; +import { + DEFAULT_FINISH_REASON, + ExecuteToolCallArguments, + ExecuteToolCallResult, + MessageRole, +} from "./contracts.js"; + +const EXECUTE_TOOL_SERIALIZATION_ERROR = + '{"serialization_error":"Failed to serialize execute tool payload."}'; + +function isTypedExecuteToolPayload( + value: object, +): value is ExecuteToolCallArguments | ExecuteToolCallResult { + return value instanceof ExecuteToolCallArguments || value instanceof ExecuteToolCallResult; +} /** * Type guard that returns `true` when the input is a structured wrapper @@ -105,6 +119,26 @@ export function serializeMessages(wrapper: InputMessages | OutputMessages): stri } } +/** + * Serializes execute-tool payload objects while keeping telemetry recording non-throwing. + * Returns `undefined` for nullish payloads so callers can omit the attribute. + */ +export function serializeToolPayload(value: object | null | undefined): string | undefined { + if (value == null) { + return undefined; + } + + if (!isTypedExecuteToolPayload(value)) { + return safeSerializeToJson(value as Record, "payload"); + } + + try { + return JSON.stringify(value) ?? EXECUTE_TOOL_SERIALIZATION_ERROR; + } catch { + return EXECUTE_TOOL_SERIALIZATION_ERROR; + } +} + /** * Ensures the value is always a JSON-parseable string. * - Objects are serialized via JSON.stringify. diff --git a/src/a365/scopes/ExecuteToolScope.ts b/src/a365/scopes/ExecuteToolScope.ts index 84b4b908..380a1589 100644 --- a/src/a365/scopes/ExecuteToolScope.ts +++ b/src/a365/scopes/ExecuteToolScope.ts @@ -4,10 +4,11 @@ import { SpanKind } from "@opentelemetry/api"; import { OpenTelemetryScope } from "./OpenTelemetryScope.js"; import { OpenTelemetryConstants } from "../constants.js"; -import { safeSerializeToJson } from "../message-utils.js"; +import { safeSerializeToJson, serializeToolPayload } from "../message-utils.js"; import type { ToolCallDetails, AgentDetails, + ExecuteToolCallResult, UserDetails, Request, SpanDetails, @@ -65,7 +66,9 @@ export class ExecuteToolScope extends OpenTelemetryScope { this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_NAME_KEY, toolName); this.setTagMaybe( OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, - args != null ? safeSerializeToJson(args, "arguments") : undefined, + typeof args === "string" + ? safeSerializeToJson(args, "arguments") + : serializeToolPayload(args), ); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_TYPE_KEY, toolType); this.setTagMaybe(OpenTelemetryConstants.GEN_AI_TOOL_CALL_ID_KEY, toolCallId); @@ -87,10 +90,16 @@ export class ExecuteToolScope extends OpenTelemetryScope { * Records response information for telemetry tracking. * Objects are serialized to JSON automatically. */ - public recordResponse(response: Record | string): void { + public recordResponse(response: ExecuteToolCallResult | null | undefined): void; + public recordResponse(response: Record | string): void; + public recordResponse( + response: Record | ExecuteToolCallResult | string | null | undefined, + ): void { this.setTagMaybe( OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, - safeSerializeToJson(response, "result"), + typeof response === "string" + ? safeSerializeToJson(response, "result") + : serializeToolPayload(response), ); } } diff --git a/src/a365/tool-call-models.ts b/src/a365/tool-call-models.ts new file mode 100644 index 00000000..eeeb1423 --- /dev/null +++ b/src/a365/tool-call-models.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** Action requested by an execute tool call. */ +export enum ToolCallAction { + /** Create a resource. */ + CREATE = "create", + /** Read a resource. */ + READ = "read", + /** Update a resource. */ + UPDATE = "update", + /** Delete a resource. */ + DELETE = "delete", +} + +/** Outcome status reported for an execute tool call result. */ +export enum ToolCallOutcomeStatus { + /** The tool call completed successfully. */ + SUCCESS = "success", + /** The tool call failed. */ + FAILURE = "failure", +} + +/** Policy decision recorded for an execute tool call result. */ +export enum ToolPolicyDecision { + /** The policy allows the tool call. */ + ALLOW = "allow", + /** The policy denies the tool call. */ + DENY = "deny", +} + +/** Resource identifier details for an execute tool call. */ +export interface ToolCallIdentifier { + /** Identifier type. */ + type?: string; + /** Identifier value. */ + value?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Container metadata for a resource reference. */ +export interface ToolCallContainer { + /** Container identifier. */ + id?: string; + /** Container URI. */ + uri?: string; + /** Container type. */ + type?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Resource metadata for an execute tool call. */ +export interface ToolCallResource { + /** Resource identifier. */ + id?: string; + /** Resource URI. */ + uri?: string; + /** Resource name. */ + name?: string; + /** Resource type. */ + type?: string; + /** Resource provider. */ + provider?: string; + /** Provider-specific identifiers for the resource. */ + identifiers?: ToolCallIdentifier[]; + /** Container that owns the resource. */ + container?: ToolCallContainer; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Outcome details for an execute tool call result. */ +export interface ToolCallResultOutcome { + /** Whether the tool call succeeded or failed. */ + status?: ToolCallOutcomeStatus; + /** Tool-specific result code. */ + code?: string; + /** Provider-specific result code. */ + provider_code?: string; + /** Human-readable outcome message. */ + message?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Sensitivity metadata for a tool call result. */ +export interface ToolCallResultSensitivity { + /** Sensitivity label identifier. */ + label_id?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Policy metadata for a tool call result. */ +export interface ToolCallResultPolicy { + /** Policy decision for the tool call. */ + decision?: ToolPolicyDecision; + /** Policy identifier. */ + id?: string; + /** Policy name. */ + name?: string; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Security metadata for a tool call result. */ +export interface ToolCallResultSecurity { + /** Whether XPIA was detected. */ + xpia_detected?: boolean; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Pagination metadata for a tool call result. */ +export interface ToolCallResultPagination { + /** Whether more results are available. */ + has_more?: boolean; + /** Cursor for the next page of results. */ + next_cursor?: string; + /** Total result count when known. */ + total_count?: number; + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; +} + +/** Resource payload returned by an execute tool call. */ +export interface ToolCallResultResource extends ToolCallResource { + /** Outcome for this resource. */ + outcome?: ToolCallResultOutcome; + /** Sensitivity metadata for this resource. */ + sensitivity?: ToolCallResultSensitivity; + /** Policy metadata for this resource. */ + policy?: ToolCallResultPolicy; + /** Security metadata for this resource. */ + security?: ToolCallResultSecurity; + /** Resource-specific result data. */ + data?: Record; +} + +/** Structured arguments for an execute tool call. */ +export class ExecuteToolCallArguments { + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; + + /** Schema version for this payload. */ + schema_version: string; + /** Resources referenced by the tool call. */ + resources?: ToolCallResource[]; + /** Requested action for the tool call. */ + action?: ToolCallAction; + /** Tool parameters for the call. */ + parameters?: Record; + + constructor(init: Partial = {}) { + Object.assign(this, init); + this.schema_version = init.schema_version ?? "1.0"; + } +} + +/** Structured result for an execute tool call. */ +export class ExecuteToolCallResult { + /** Provider-specific properties not defined by the schema. */ + [key: string]: unknown; + + /** Schema version for this payload. */ + schema_version: string; + /** Overall tool call outcome. */ + outcome?: ToolCallResultOutcome; + /** Resources returned by the tool call. */ + resources?: ToolCallResultResource[]; + /** Tool result data. */ + data?: Record; + /** Pagination metadata for the result set. */ + pagination?: ToolCallResultPagination; + + constructor(init: Partial = {}) { + Object.assign(this, init); + this.schema_version = init.schema_version ?? "1.0"; + } +} diff --git a/src/index.ts b/src/index.ts index a2869127..f1e71b1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,11 @@ export { InvocationRole, InferenceOperationType, DEFAULT_FINISH_REASON, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, + ExecuteToolCallArguments, + ExecuteToolCallResult, GuardrailDecisionType, GuardrailRiskSeverity, GuardrailTargetType, @@ -90,6 +95,15 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallIdentifier, + ToolCallContainer, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultSensitivity, + ToolCallResultPolicy, + ToolCallResultSecurity, + ToolCallResultPagination, + ToolCallResultResource, HeadersCarrier, GuardrailDetails, GuardrailFinding, diff --git a/test/internal/unit/a365/executeToolJsonModels.test.ts b/test/internal/unit/a365/executeToolJsonModels.test.ts new file mode 100644 index 00000000..40f3bb75 --- /dev/null +++ b/test/internal/unit/a365/executeToolJsonModels.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, expectTypeOf, it } from "vitest"; + +import * as a365 from "../../../../src/a365/index.js"; +import * as rootExports from "../../../../src/index.js"; +import type { ToolCallDetails } from "../../../../src/a365/index.js"; + +describe("execute tool JSON models", () => { + it("exports execute tool model values from the A365 and root barrels", () => { + expect(a365.ExecuteToolCallArguments).toBeDefined(); + expect(a365.ExecuteToolCallResult).toBeDefined(); + expect(a365.ToolCallAction).toBeDefined(); + expect(a365.ToolCallOutcomeStatus).toBeDefined(); + expect(a365.ToolPolicyDecision).toBeDefined(); + + expect(rootExports.ExecuteToolCallArguments).toBe(a365.ExecuteToolCallArguments); + expect(rootExports.ExecuteToolCallResult).toBe(a365.ExecuteToolCallResult); + expect(rootExports.ToolCallAction).toBe(a365.ToolCallAction); + expect(rootExports.ToolCallOutcomeStatus).toBe(a365.ToolCallOutcomeStatus); + expect(rootExports.ToolPolicyDecision).toBe(a365.ToolPolicyDecision); + }); + + it("defaults schema_version when execute tool call arguments are constructed with no input", () => { + const argumentsModel = new a365.ExecuteToolCallArguments(); + + expect(argumentsModel).toEqual({ schema_version: "1.0" }); + }); + + it("defaults schema_version when execute tool call results are constructed with no input", () => { + const resultModel = new a365.ExecuteToolCallResult(); + + expect(resultModel).toEqual({ schema_version: "1.0" }); + }); + + it("defaults schema_version on execute tool call arguments and preserves explicit values", () => { + const defaultArgs = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + resources: [ + { + id: "drive-item-1", + uri: "https://contoso.example/items/1", + name: "Quarterly plan", + type: "document", + provider: "sharepoint", + identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + label_id: "container-label", + }, + custom_resource_field: true, + }, + ], + parameters: { query: "plan" }, + top_level_extra: "kept", + }); + + expect(defaultArgs).toMatchObject({ + schema_version: "1.0", + action: "read", + resources: [ + { + identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], + container: { label_id: "container-label" }, + custom_resource_field: true, + }, + ], + top_level_extra: "kept", + }); + + const explicitArgs = new a365.ExecuteToolCallArguments({ schema_version: "2.0" }); + expect(explicitArgs.schema_version).toBe("2.0"); + }); + + it("allows execute tool call argument models in ToolCallDetails.arguments", () => { + const argumentsModel = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + parameters: { query: "plan" }, + }); + const existingObjectArguments: ToolCallDetails = { + toolName: "search", + arguments: { query: "plan" }, + }; + const existingStringArguments: ToolCallDetails = { + toolName: "search", + arguments: '{"query":"plan"}', + }; + const details: ToolCallDetails = { + toolName: "search", + arguments: argumentsModel, + }; + + expect(existingObjectArguments.arguments).toEqual({ query: "plan" }); + expect(existingStringArguments.arguments).toBe('{"query":"plan"}'); + expect(details.arguments).toBe(argumentsModel); + expectTypeOf(details.arguments).toMatchTypeOf | string | undefined>(); + }); + + it("defaults schema_version on execute tool call results and preserves exact wire fields", () => { + const defaultResult = new a365.ExecuteToolCallResult({ + outcome: { + status: a365.ToolCallOutcomeStatus.SUCCESS, + code: "200", + provider_code: "graph-ok", + message: "Completed", + }, + resources: [ + { + id: "doc-1", + uri: "https://contoso.example/items/1", + name: "Quarterly plan", + type: "document", + provider: "sharepoint", + identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], + container: { + id: "folder-1", + uri: "https://contoso.example/folders/1", + type: "folder", + }, + outcome: { + status: a365.ToolCallOutcomeStatus.FAILURE, + provider_code: "partial-failure", + message: "1 record skipped", + }, + sensitivity: { label_id: "secret", sensitivity_extra: "kept" }, + policy: { + decision: a365.ToolPolicyDecision.ALLOW, + id: "policy-1", + name: "AllowPolicy", + }, + security: { xpia_detected: true }, + data: { skipped: 1 }, + resource_extra: "kept", + }, + ], + data: { documents: 1 }, + pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, + result_extra: "kept", + }); + + expect(defaultResult).toMatchObject({ + schema_version: "1.0", + outcome: { + status: "success", + provider_code: "graph-ok", + }, + resources: [ + { + outcome: { status: "failure", provider_code: "partial-failure" }, + sensitivity: { label_id: "secret", sensitivity_extra: "kept" }, + policy: { decision: "allow" }, + security: { xpia_detected: true }, + resource_extra: "kept", + }, + ], + pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, + result_extra: "kept", + }); + + const explicitResult = new a365.ExecuteToolCallResult({ schema_version: "2.1" }); + expect(explicitResult.schema_version).toBe("2.1"); + }); +}); diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index 7430cada..52a77f1f 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -3,7 +3,14 @@ import { describe, it, expect } from "vitest"; -import { MessageRole, Modality } from "../../../../src/a365/contracts.js"; +import { + ExecuteToolCallArguments, + ExecuteToolCallResult, + MessageRole, + Modality, + ToolCallAction, + ToolCallOutcomeStatus, +} from "../../../../src/a365/contracts.js"; import type { InputMessages, OutputMessages } from "../../../../src/a365/contracts.js"; import { isWrappedMessages, @@ -12,6 +19,7 @@ import { normalizeInputMessages, normalizeOutputMessages, serializeMessages, + serializeToolPayload, } from "../../../../src/a365/message-utils.js"; describe("isWrappedMessages", () => { @@ -318,3 +326,85 @@ describe("serializeMessages", () => { expect(parsed[2].parts[0].type).toBe("custom_annotation"); }); }); + +describe("serializeToolPayload", () => { + const serializationError = '{"serialization_error":"Failed to serialize execute tool payload."}'; + const legacySerializationError = '{"error":"serialization failed"}'; + + it("returns undefined for nullish payloads", () => { + expect(serializeToolPayload(undefined)).toBeUndefined(); + expect(serializeToolPayload(null)).toBeUndefined(); + }); + + it("serializes typed payloads with schema version, nested values, and extension fields", () => { + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + provider_resource_type: "page", + }, + ], + request_context: { scenario: "enterprise-search" }, + }); + + const serialized = serializeToolPayload(payload); + const parsed = JSON.parse(serialized as string); + + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.action).toBe("read"); + expect(parsed.parameters.filters).toEqual({ + sensitivity: "high", + includeArchived: true, + }); + expect(parsed.resources[0].provider_resource_type).toBe("page"); + expect(parsed.request_context).toEqual({ scenario: "enterprise-search" }); + }); + + it("returns the legacy fallback for circular generic payloads", () => { + const payload: Record = { a: 1 }; + payload.self = payload; + + expect(serializeToolPayload(payload)).toBe(legacySerializationError); + }); + + it("returns the exact fallback for circular ExecuteToolCallArguments payloads", () => { + const payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ }); + payload.self = payload; + + expect(serializeToolPayload(payload)).toBe(serializationError); + }); + + it("returns the exact fallback for circular ExecuteToolCallResult payloads", () => { + const result = new ExecuteToolCallResult({ + outcome: { status: ToolCallOutcomeStatus.SUCCESS }, + data: { count: 1 }, + }); + result.self = result; + + expect(serializeToolPayload(result)).toBe(serializationError); + }); + + it("returns the exact fallback for bigint payloads", () => { + expect( + serializeToolPayload( + new ExecuteToolCallArguments({ action: ToolCallAction.READ, count: BigInt(1) }), + ), + ).toBe(serializationError); + }); + + it("returns the exact fallback when payload serialization throws", () => { + const payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ }); + payload.toJSON = () => { + throw new Error("boom"); + }; + + expect(serializeToolPayload(payload)).toBe(serializationError); + }); +}); diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 4c51f71e..5be6fcd5 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -13,11 +13,15 @@ import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-ho import { ExecuteToolScope, + ExecuteToolCallArguments, + ExecuteToolCallResult, InvokeAgentScope, InferenceScope, OutputScope, OpenTelemetryScope, OpenTelemetryConstants, + ToolCallAction, + ToolCallOutcomeStatus, } from "../../../../src/a365/index.js"; import type { AgentDetails, @@ -521,6 +525,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.GEN_AI_CALLER_CLIENT_IP_KEY, val: "10.0.0.10", }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY, + val: '{"param": "value"}', + }), ]), ); @@ -558,6 +566,10 @@ describe("Scopes", () => { key: OpenTelemetryConstants.CHANNEL_LINK_KEY, val: "https://web.link", }), + expect.objectContaining({ + key: OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY, + val: '{"result":"Tool result"}', + }), ]), ); scope?.dispose(); @@ -1245,6 +1257,10 @@ describe("Request content and message serialization (span attributes)", () => { }); describe("ExecuteToolScope – tool args and response serialization", () => { + const serializationError = + '{"serialization_error":"Failed to serialize execute tool payload."}'; + const legacySerializationError = '{"error":"serialization failed"}'; + it("should serialize object arguments to span attribute", () => { const objArgs = { query: "GDPR", maxResults: 5 }; const scope = ExecuteToolScope.start( @@ -1269,6 +1285,182 @@ describe("Request content and message serialization (span attributes)", () => { JSON.stringify(objResponse), ); }); + + it("should serialize typed arguments with schema version, nested values, and extension fields", () => { + const typedArgs = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + provider_resource_type: "page", + }, + ], + request_context: { scenario: "enterprise-search" }, + }); + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: typedArgs }, + testAgentDetails, + ); + scope.dispose(); + + const parsed = JSON.parse( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY] as string, + ); + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.action).toBe("read"); + expect(parsed.parameters.filters).toEqual({ + sensitivity: "high", + includeArchived: true, + }); + expect(parsed.resources[0].provider_resource_type).toBe("page"); + expect(parsed.request_context).toEqual({ scenario: "enterprise-search" }); + }); + + it("should serialize typed results with nested outcome and extension fields", () => { + const typedResult = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + message: "Fetched 1 document", + provider_code: "OK", + retryable: false, + }, + resources: [ + { + id: "doc-1", + type: "document", + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + message: "available", + provider_status: "complete", + }, + data: { title: "Doc A" }, + relevance_score: 0.95, + }, + ], + pagination: { + has_more: false, + total_count: 1, + request_charge: 3, + }, + source_trace: { provider: "sharepoint" }, + }); + + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(typedResult); + scope.dispose(); + + const parsed = JSON.parse( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY] as string, + ); + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.outcome).toEqual({ + status: "success", + message: "Fetched 1 document", + provider_code: "OK", + retryable: false, + }); + expect(parsed.resources[0].outcome.provider_status).toBe("complete"); + expect(parsed.resources[0].relevance_score).toBe(0.95); + expect(parsed.pagination).toEqual({ + has_more: false, + total_count: 1, + request_charge: 3, + }); + expect(parsed.source_trace).toEqual({ provider: "sharepoint" }); + }); + + it("should omit the typed result attribute when response is undefined", () => { + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(undefined); + scope.dispose(); + + expect( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], + ).toBeUndefined(); + }); + + it("should omit the typed result attribute when response is null", () => { + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(null); + scope.dispose(); + + expect( + getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY], + ).toBeUndefined(); + }); + + it("should preserve the legacy fallback for circular object arguments", () => { + const circular: Record = { query: "GDPR" }; + circular.self = circular; + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: circular }, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY]).toBe( + legacySerializationError, + ); + }); + + it("should use the typed fallback for circular ExecuteToolCallArguments instances", () => { + const typedArgs = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + parameters: { query: "GDPR" }, + }); + typedArgs.self = typedArgs; + + const scope = ExecuteToolScope.start( + testRequest, + { toolName: "search", arguments: typedArgs }, + testAgentDetails, + ); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_ARGS_KEY]).toBe( + serializationError, + ); + }); + + it("should preserve the legacy fallback for circular object responses", () => { + const circular: Record = { results: ["Doc A"] }; + circular.self = circular; + + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(circular); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY]).toBe( + legacySerializationError, + ); + }); + + it("should use the typed fallback for circular ExecuteToolCallResult instances", () => { + const typedResult = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + }, + }); + typedResult.self = typedResult; + + const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); + scope.recordResponse(typedResult); + scope.dispose(); + + expect(getLastSpan().attributes[OpenTelemetryConstants.GEN_AI_TOOL_CALL_RESULT_KEY]).toBe( + serializationError, + ); + }); }); }); diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json new file mode 100644 index 00000000..41ee652d --- /dev/null +++ b/tsconfig.type-tests.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["src", "test/internal/unit/a365/executeToolJsonModels.test.ts"] +} From 8f820e2db6508c801379399d124c7978fa049f03 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Mon, 14 Sep 2026 07:06:50 -0600 Subject: [PATCH 02/11] docs: link ExecuteTool changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2411433c..c8a874c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. ### Features Added -- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". +- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) ## [1.4.0] - 2026-09-08 From c3250ee2b1f7d961e8e577e89d6ab0ed13284dfa Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 15 Sep 2026 15:53:19 -0600 Subject: [PATCH 03/11] docs: normalize ExecuteTool changelog placement Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ccc29b9-bf5d-4725-be3b-c4276b233c4c --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a874c1..8b8990c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,12 @@ ## [Unreleased] -### Other Changes -- Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. - ### Features Added - Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) +### Other Changes +- Consolidate Dependabot updates for Vitest 4.1.11, Hono 4.13.7, qs 6.16.0, fast-uri 3.1.7, actions/deploy-pages 5.0.1, and actions/checkout 7.0.1. + ## [1.4.0] - 2026-09-08 ### Features Added From 4a8834e9557478cfecd1dd69849c0aa328de01ec Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:00:34 -0600 Subject: [PATCH 04/11] docs: design execute tool schema parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- ...09-29-execute-tool-schema-parity-design.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md diff --git a/docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md b/docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md new file mode 100644 index 00000000..9a7dfb06 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md @@ -0,0 +1,48 @@ +# ExecuteTool Schema Parity Design + +## Goal + +Align the JavaScript typed ExecuteTool payload experience with the current .NET and Python implementations while preserving the existing behavior for legacy raw object and string payloads. + +## Alternatives considered + +1. **Explicit extension data serialized as `metadata` (selected).** Declared schema fields remain authoritative, provider fields cannot collide with them, and the wire shape matches Python and the latest .NET POCO models. +2. **Flatten extension data and reject collisions.** This prevents overwrites but produces a different wire contract from the other SDKs. +3. **Keep flat last-write-wins properties.** This preserves the current PR implementation but permits silent schema-field replacement and does not fix the reported issue. + +## Model contract + +Every typed schema model exposes an optional `extension_data` mapping. During serialization, a non-empty mapping is emitted as `metadata`. Extension entries are never merged into the model object, so keys such as `action`, `schema_version`, `status`, and `code` remain isolated inside `metadata`. + +Declared model properties that are `null` or `undefined` are omitted. Empty arrays, empty objects, `false`, zero, and empty strings are preserved. Values inside caller-provided mappings and arrays preserve explicit `null`. + +## Serialization + +Typed ExecuteTool models use a dedicated recursive serializer rather than native `JSON.stringify` directly: + +- validate `action`, outcome `status`, and policy `decision` against their public enum tokens; +- reject non-finite numbers, `bigint`, functions, symbols, unsupported object instances, and cyclic references; +- preserve repeated non-cyclic references; +- serialize `Date` values as ISO-8601 strings, byte arrays as base64, sets as arrays, and enum members as their string values; +- require extension data and nested mappings to be ordinary string-keyed records; +- return the existing diagnostic JSON for any typed-payload serialization failure. + +Legacy untyped payloads continue through `safeSerializeToJson` unchanged. + +## Components + +- `tool-call-models.ts` defines the public `extension_data` fields and removes the open index signatures that currently allow undeclared fields to collide with schema properties. +- `message-utils.ts` owns schema-aware conversion and safe typed-payload serialization. +- Unit tests cover top-level and nested collisions, metadata omission, null semantics, enum validation, non-finite numbers, unsupported values, cycles, repeated references, and ordinary successful payloads. +- Documentation examples use `extension_data` and show the emitted `metadata` wire shape. + +## Compatibility + +The feature is not yet released, so correcting the typed model construction API does not break an existing published contract. Existing raw `Record` and JSON string ToolCallDetails payloads retain their current behavior. + +## Success criteria + +- JavaScript collision behavior and wire output match .NET and Python. +- A regression test fails if extension keys are flattened or can replace declared fields. +- Typed serialization never throws and never silently coerces invalid schema values. +- Existing tests, type checks, build, formatting, and linting pass. From 99fa1d37fd81bea200fd8c7c2647a957071caf71 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:01:19 -0600 Subject: [PATCH 05/11] docs: plan execute tool schema parity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- .../2026-09-29-execute-tool-schema-parity.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md diff --git a/docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md b/docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md new file mode 100644 index 00000000..8c43fb22 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md @@ -0,0 +1,240 @@ +# ExecuteTool Schema Parity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make typed ExecuteTool models and serialization match the current .NET and Python collision, metadata, validation, and failure behavior. + +**Architecture:** Typed models expose `extension_data`, which the serializer emits under `metadata` instead of flattening into declared fields. A schema-aware recursive converter validates enum fields and JSON-compatible values before one final `JSON.stringify`, while legacy raw payloads keep the existing path. + +**Tech Stack:** TypeScript, Vitest, OpenTelemetry JavaScript API, npm scripts. + +## Global Constraints + +- Preserve existing raw `Record` and JSON string payload behavior. +- Emit the exact diagnostic JSON `{"serialization_error":"Failed to serialize execute tool payload."}` for every typed serialization failure. +- Omit nullish declared model fields and preserve explicit nulls inside mappings and arrays. +- Add no dependencies. + +--- + +### Task 1: Public extension-data model contract + +**Files:** +- Modify: `src/a365/tool-call-models.ts` +- Modify: `test/internal/unit/a365/executeToolJsonModels.test.ts` + +**Interfaces:** +- Produces: `extension_data?: Record` on every ExecuteTool schema model. +- Produces: `ExecuteToolCallArguments` and `ExecuteToolCallResult` constructors that copy only declared properties. + +- [ ] **Step 1: Write failing model tests** + +Add tests constructing top-level and nested models with: + +```ts +const payload = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + extension_data: { action: "write", schema_version: "9.9" }, +}); + +expect(payload.action).toBe("read"); +expect(payload.schema_version).toBe("1.0"); +expect(payload.extension_data).toEqual({ action: "write", schema_version: "9.9" }); +``` + +Update existing custom-field fixtures to use `extension_data`. + +- [ ] **Step 2: Run the focused test and confirm failure** + +Run: `npm run test:unit -- test/internal/unit/a365/executeToolJsonModels.test.ts` + +Expected: FAIL because `extension_data` is not the established extensibility contract and current constructors copy arbitrary keys. + +- [ ] **Step 3: Implement the explicit model contract** + +Replace open index signatures with: + +```ts +export interface ToolCallExtensionData { + extension_data?: Record; +} +``` + +Extend each nested interface from `ToolCallExtensionData`. Make both top-level classes implement it and explicitly assign only `schema_version`, declared fields, and `extension_data` in their constructors. + +- [ ] **Step 4: Run the focused test** + +Run: `npm run test:unit -- test/internal/unit/a365/executeToolJsonModels.test.ts` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```powershell +git add src\a365\tool-call-models.ts test\internal\unit\a365\executeToolJsonModels.test.ts +git commit -m "fix(a365): isolate execute tool extension data" +``` + +### Task 2: Strict typed-payload serializer + +**Files:** +- Modify: `src/a365/message-utils.ts` +- Modify: `test/internal/unit/a365/messageUtils.test.ts` + +**Interfaces:** +- Consumes: `extension_data?: Record` from Task 1. +- Produces: `serializeToolPayload(value: object | null | undefined): string | undefined`. +- Produces internal schema converters for arguments, results, resources, outcomes, pagination, policies, and generic mapping values. + +- [ ] **Step 1: Write failing collision and wire-shape tests** + +Add assertions equivalent to: + +```ts +const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data: { action: "write", schema_version: "9.9" }, +}); + +expect(JSON.parse(serializeToolPayload(payload)!)).toEqual({ + schema_version: "1.0", + action: "read", + metadata: { action: "write", schema_version: "9.9" }, +}); +``` + +Add a nested outcome collision test where declared `code: "ok"` and `extension_data.code: "provider-code"` remain separate. + +- [ ] **Step 2: Write failing validation and null-semantics tests** + +Cover: + +```ts +expect(serializeToolPayload(new ExecuteToolCallArguments({ action: "READ" as any }))) + .toBe(EXPECTED_ERROR_JSON); +expect(serializeToolPayload(new ExecuteToolCallResult({ data: { score: Number.NaN } }))) + .toBe(EXPECTED_ERROR_JSON); +expect(JSON.parse(serializeToolPayload(new ExecuteToolCallResult({ + outcome: { status: ToolCallOutcomeStatus.SUCCESS, provider_code: null as any, + extension_data: { provider_outcome: null } }, + data: { content: null, matches: [null, 1] }, +}))!)).toEqual({ + schema_version: "1.0", + outcome: { status: "success", metadata: { provider_outcome: null } }, + data: { content: null, matches: [null, 1] }, +}); +``` + +Also cover `Infinity`, functions, `undefined` mapping values, `bigint`, cycles, repeated references, `Date`, `Uint8Array`, and `Set`. + +- [ ] **Step 3: Run focused tests and confirm failure** + +Run: `npm run test:unit -- test/internal/unit/a365/messageUtils.test.ts` + +Expected: FAIL because native `JSON.stringify` flattens current model fields and silently coerces invalid values. + +- [ ] **Step 4: Implement schema-aware conversion** + +Add internal helpers with these responsibilities: + +```ts +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +function serializeTypedToolPayload( + value: ExecuteToolCallArguments | ExecuteToolCallResult, +): string; + +function toJsonValue(value: unknown, stack: Set): JsonValue; +function toJsonRecord(value: Record, stack: Set): Record; +function withMetadata( + declared: Record, + extensionData: Record | undefined, + stack: Set, +): Record; +function validateEnum(value: unknown, allowed: ReadonlySet, field: string): string; +``` + +Use explicit converters for every schema interface. Omit nullish declared fields, convert non-empty `extension_data` to `metadata`, preserve mapping nulls, reject unsupported/coerced values, and remove containers from the active stack in `finally` so repeated references remain valid. + +- [ ] **Step 5: Run focused tests** + +Run: `npm run test:unit -- test/internal/unit/a365/messageUtils.test.ts` + +Expected: PASS. + +- [ ] **Step 6: Run both ExecuteTool unit files** + +Run: `npm run test:unit -- test/internal/unit/a365/executeToolJsonModels.test.ts test/internal/unit/a365/messageUtils.test.ts` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +git add src\a365\message-utils.ts test\internal\unit\a365\messageUtils.test.ts +git commit -m "fix(a365): validate typed execute tool payloads" +``` + +### Task 3: Documentation and repository validation + +**Files:** +- Modify: `A365_DOCUMENTATION.md` +- Modify: `CHANGELOG.md` only if the existing entry describes flattened custom fields. + +**Interfaces:** +- Documents: `extension_data` construction API and emitted `metadata` JSON. + +- [ ] **Step 1: Update documentation examples** + +Replace direct custom properties with: + +```ts +extension_data: { + provider_trace_id: "trace-789", +} +``` + +Show that the emitted JSON contains: + +```json +"metadata": { + "provider_trace_id": "trace-789" +} +``` + +State that metadata keys may match declared field names without replacing those declared fields. + +- [ ] **Step 2: Run formatting** + +Run: `npm run format` + +Expected: exits 0. + +- [ ] **Step 3: Run type checks, build, lint, and unit tests** + +Run: `npm run typecheck:test && npm run build && npm run lint && npm run test:unit` + +Expected: all commands exit 0; lint may report only repository-baseline warnings. + +- [ ] **Step 4: Run remaining PR validation suites** + +Run: `npm run test:functional && npm run test:esm-build` + +Expected: all commands exit 0. + +- [ ] **Step 5: Inspect and commit final changes** + +Run: `git diff --check && git status --short` + +Expected: no whitespace errors and only intended files changed. + +```powershell +git add A365_DOCUMENTATION.md CHANGELOG.md +git commit -m "docs(a365): document execute tool metadata" +``` + +- [ ] **Step 6: Push the PR branch** + +Run: `git push origin feature/a365-execute-tool-schemas` + +Expected: the remote PR head updates successfully and CI starts. From fe520925215af2acdfea698620a8755e44626fcb Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:05:09 -0600 Subject: [PATCH 06/11] fix(a365): isolate execute tool extension data Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- src/a365/contracts.ts | 1 + src/a365/index.ts | 1 + src/a365/tool-call-models.ts | 81 ++++++++--------- src/index.ts | 1 + .../unit/a365/executeToolJsonModels.test.ts | 87 +++++++++++++++---- 5 files changed, 111 insertions(+), 60 deletions(-) diff --git a/src/a365/contracts.ts b/src/a365/contracts.ts index ce5dcc8c..5867ee4c 100644 --- a/src/a365/contracts.ts +++ b/src/a365/contracts.ts @@ -19,6 +19,7 @@ export { ExecuteToolCallResult, } from "./tool-call-models.js"; export type { + ToolCallExtensionData, ToolCallIdentifier, ToolCallContainer, ToolCallResource, diff --git a/src/a365/index.ts b/src/a365/index.ts index 554af042..8ea2ac8f 100644 --- a/src/a365/index.ts +++ b/src/a365/index.ts @@ -61,6 +61,7 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallExtensionData, ToolCallIdentifier, ToolCallContainer, ToolCallResource, diff --git a/src/a365/tool-call-models.ts b/src/a365/tool-call-models.ts index eeeb1423..df62761e 100644 --- a/src/a365/tool-call-models.ts +++ b/src/a365/tool-call-models.ts @@ -29,30 +29,32 @@ export enum ToolPolicyDecision { DENY = "deny", } +/** Provider-specific properties not defined by an execute tool schema model. */ +export interface ToolCallExtensionData { + /** Properties serialized under the `metadata` wire field. */ + extension_data?: Record; +} + /** Resource identifier details for an execute tool call. */ -export interface ToolCallIdentifier { +export interface ToolCallIdentifier extends ToolCallExtensionData { /** Identifier type. */ type?: string; /** Identifier value. */ value?: string; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Container metadata for a resource reference. */ -export interface ToolCallContainer { +export interface ToolCallContainer extends ToolCallExtensionData { /** Container identifier. */ id?: string; /** Container URI. */ uri?: string; /** Container type. */ type?: string; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Resource metadata for an execute tool call. */ -export interface ToolCallResource { +export interface ToolCallResource extends ToolCallExtensionData { /** Resource identifier. */ id?: string; /** Resource URI. */ @@ -67,12 +69,10 @@ export interface ToolCallResource { identifiers?: ToolCallIdentifier[]; /** Container that owns the resource. */ container?: ToolCallContainer; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Outcome details for an execute tool call result. */ -export interface ToolCallResultOutcome { +export interface ToolCallResultOutcome extends ToolCallExtensionData { /** Whether the tool call succeeded or failed. */ status?: ToolCallOutcomeStatus; /** Tool-specific result code. */ @@ -81,48 +81,38 @@ export interface ToolCallResultOutcome { provider_code?: string; /** Human-readable outcome message. */ message?: string; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Sensitivity metadata for a tool call result. */ -export interface ToolCallResultSensitivity { +export interface ToolCallResultSensitivity extends ToolCallExtensionData { /** Sensitivity label identifier. */ label_id?: string; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Policy metadata for a tool call result. */ -export interface ToolCallResultPolicy { +export interface ToolCallResultPolicy extends ToolCallExtensionData { /** Policy decision for the tool call. */ decision?: ToolPolicyDecision; /** Policy identifier. */ id?: string; /** Policy name. */ name?: string; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Security metadata for a tool call result. */ -export interface ToolCallResultSecurity { +export interface ToolCallResultSecurity extends ToolCallExtensionData { /** Whether XPIA was detected. */ xpia_detected?: boolean; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Pagination metadata for a tool call result. */ -export interface ToolCallResultPagination { +export interface ToolCallResultPagination extends ToolCallExtensionData { /** Whether more results are available. */ has_more?: boolean; /** Cursor for the next page of results. */ next_cursor?: string; /** Total result count when known. */ total_count?: number; - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; } /** Resource payload returned by an execute tool call. */ @@ -140,43 +130,48 @@ export interface ToolCallResultResource extends ToolCallResource { } /** Structured arguments for an execute tool call. */ -export class ExecuteToolCallArguments { - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; - +export class ExecuteToolCallArguments implements ToolCallExtensionData { /** Schema version for this payload. */ - schema_version: string; + declare schema_version: string; /** Resources referenced by the tool call. */ - resources?: ToolCallResource[]; + declare resources?: ToolCallResource[]; /** Requested action for the tool call. */ - action?: ToolCallAction; + declare action?: ToolCallAction; /** Tool parameters for the call. */ - parameters?: Record; + declare parameters?: Record; + /** Provider-specific properties serialized under `metadata`. */ + declare extension_data?: Record; constructor(init: Partial = {}) { - Object.assign(this, init); this.schema_version = init.schema_version ?? "1.0"; + if (init.resources !== undefined) this.resources = init.resources; + if (init.action !== undefined) this.action = init.action; + if (init.parameters !== undefined) this.parameters = init.parameters; + if (init.extension_data !== undefined) this.extension_data = init.extension_data; } } /** Structured result for an execute tool call. */ -export class ExecuteToolCallResult { - /** Provider-specific properties not defined by the schema. */ - [key: string]: unknown; - +export class ExecuteToolCallResult implements ToolCallExtensionData { /** Schema version for this payload. */ - schema_version: string; + declare schema_version: string; /** Overall tool call outcome. */ - outcome?: ToolCallResultOutcome; + declare outcome?: ToolCallResultOutcome; /** Resources returned by the tool call. */ - resources?: ToolCallResultResource[]; + declare resources?: ToolCallResultResource[]; /** Tool result data. */ - data?: Record; + declare data?: Record; /** Pagination metadata for the result set. */ - pagination?: ToolCallResultPagination; + declare pagination?: ToolCallResultPagination; + /** Provider-specific properties serialized under `metadata`. */ + declare extension_data?: Record; constructor(init: Partial = {}) { - Object.assign(this, init); this.schema_version = init.schema_version ?? "1.0"; + if (init.outcome !== undefined) this.outcome = init.outcome; + if (init.resources !== undefined) this.resources = init.resources; + if (init.data !== undefined) this.data = init.data; + if (init.pagination !== undefined) this.pagination = init.pagination; + if (init.extension_data !== undefined) this.extension_data = init.extension_data; } } diff --git a/src/index.ts b/src/index.ts index 546894d7..e5f45643 100644 --- a/src/index.ts +++ b/src/index.ts @@ -98,6 +98,7 @@ export type { ToolCallRequestPart, ToolCallResponsePart, ReasoningPart, + ToolCallExtensionData, ToolCallIdentifier, ToolCallContainer, ToolCallResource, diff --git a/test/internal/unit/a365/executeToolJsonModels.test.ts b/test/internal/unit/a365/executeToolJsonModels.test.ts index 40f3bb75..67e764f3 100644 --- a/test/internal/unit/a365/executeToolJsonModels.test.ts +++ b/test/internal/unit/a365/executeToolJsonModels.test.ts @@ -5,7 +5,11 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import * as a365 from "../../../../src/a365/index.js"; import * as rootExports from "../../../../src/index.js"; -import type { ToolCallDetails } from "../../../../src/a365/index.js"; +import type { + ToolCallDetails, + ToolCallExtensionData, +} from "../../../../src/a365/index.js"; +import type { ToolCallExtensionData as RootToolCallExtensionData } from "../../../../src/index.js"; describe("execute tool JSON models", () => { it("exports execute tool model values from the A365 and root barrels", () => { @@ -20,6 +24,7 @@ describe("execute tool JSON models", () => { expect(rootExports.ToolCallAction).toBe(a365.ToolCallAction); expect(rootExports.ToolCallOutcomeStatus).toBe(a365.ToolCallOutcomeStatus); expect(rootExports.ToolPolicyDecision).toBe(a365.ToolPolicyDecision); + expectTypeOf().toEqualTypeOf(); }); it("defaults schema_version when execute tool call arguments are constructed with no input", () => { @@ -44,18 +49,24 @@ describe("execute tool JSON models", () => { name: "Quarterly plan", type: "document", provider: "sharepoint", - identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], + identifiers: [ + { + type: "driveItem", + value: "1", + extension_data: { provider_code: "sp" }, + }, + ], container: { id: "folder-1", uri: "https://contoso.example/folders/1", type: "folder", - label_id: "container-label", + extension_data: { label_id: "container-label" }, }, - custom_resource_field: true, + extension_data: { custom_resource_field: true }, }, ], parameters: { query: "plan" }, - top_level_extra: "kept", + extension_data: { top_level_extra: "kept" }, }); expect(defaultArgs).toMatchObject({ @@ -63,12 +74,18 @@ describe("execute tool JSON models", () => { action: "read", resources: [ { - identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], - container: { label_id: "container-label" }, - custom_resource_field: true, + identifiers: [ + { + type: "driveItem", + value: "1", + extension_data: { provider_code: "sp" }, + }, + ], + container: { extension_data: { label_id: "container-label" } }, + extension_data: { custom_resource_field: true }, }, ], - top_level_extra: "kept", + extension_data: { top_level_extra: "kept" }, }); const explicitArgs = new a365.ExecuteToolCallArguments({ schema_version: "2.0" }); @@ -96,7 +113,9 @@ describe("execute tool JSON models", () => { expect(existingObjectArguments.arguments).toEqual({ query: "plan" }); expect(existingStringArguments.arguments).toBe('{"query":"plan"}'); expect(details.arguments).toBe(argumentsModel); - expectTypeOf(details.arguments).toMatchTypeOf | string | undefined>(); + expectTypeOf(details.arguments).toMatchTypeOf< + Record | a365.ExecuteToolCallArguments | string | undefined + >(); }); it("defaults schema_version on execute tool call results and preserves exact wire fields", () => { @@ -114,7 +133,13 @@ describe("execute tool JSON models", () => { name: "Quarterly plan", type: "document", provider: "sharepoint", - identifiers: [{ type: "driveItem", value: "1", provider_code: "sp" }], + identifiers: [ + { + type: "driveItem", + value: "1", + extension_data: { provider_code: "sp" }, + }, + ], container: { id: "folder-1", uri: "https://contoso.example/folders/1", @@ -125,7 +150,10 @@ describe("execute tool JSON models", () => { provider_code: "partial-failure", message: "1 record skipped", }, - sensitivity: { label_id: "secret", sensitivity_extra: "kept" }, + sensitivity: { + label_id: "secret", + extension_data: { sensitivity_extra: "kept" }, + }, policy: { decision: a365.ToolPolicyDecision.ALLOW, id: "policy-1", @@ -133,12 +161,12 @@ describe("execute tool JSON models", () => { }, security: { xpia_detected: true }, data: { skipped: 1 }, - resource_extra: "kept", + extension_data: { resource_extra: "kept" }, }, ], data: { documents: 1 }, pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, - result_extra: "kept", + extension_data: { result_extra: "kept" }, }); expect(defaultResult).toMatchObject({ @@ -150,17 +178,42 @@ describe("execute tool JSON models", () => { resources: [ { outcome: { status: "failure", provider_code: "partial-failure" }, - sensitivity: { label_id: "secret", sensitivity_extra: "kept" }, + sensitivity: { + label_id: "secret", + extension_data: { sensitivity_extra: "kept" }, + }, policy: { decision: "allow" }, security: { xpia_detected: true }, - resource_extra: "kept", + extension_data: { resource_extra: "kept" }, }, ], pagination: { has_more: true, next_cursor: "cursor-2", total_count: 10 }, - result_extra: "kept", + extension_data: { result_extra: "kept" }, }); const explicitResult = new a365.ExecuteToolCallResult({ schema_version: "2.1" }); expect(explicitResult.schema_version).toBe("2.1"); }); + + it("does not copy undeclared top-level properties into typed models", () => { + const argumentsModel = new a365.ExecuteToolCallArguments({ + action: a365.ToolCallAction.READ, + extension_data: { action: "write", schema_version: "9.9" }, + top_level_extra: "not-extension-data", + } as any); + const resultModel = new a365.ExecuteToolCallResult({ + extension_data: { outcome: "provider-outcome" }, + result_extra: "not-extension-data", + } as any); + + expect(argumentsModel).toEqual({ + schema_version: "1.0", + action: "read", + extension_data: { action: "write", schema_version: "9.9" }, + }); + expect(resultModel).toEqual({ + schema_version: "1.0", + extension_data: { outcome: "provider-outcome" }, + }); + }); }); From 713c601d0e529ceb91ba6d9d2773f66ee6a91d5f Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:08:53 -0600 Subject: [PATCH 07/11] fix(a365): validate typed execute tool payloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- src/a365/message-utils.ts | 398 ++++++++++++++++++- test/internal/unit/a365/messageUtils.test.ts | 196 ++++++++- test/internal/unit/a365/scopes.test.ts | 36 +- 3 files changed, 595 insertions(+), 35 deletions(-) diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts index 7029cdb9..142cadee 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -15,12 +15,24 @@ import type { InputMessagesParam, OutputMessagesParam, SystemInstructionPart, + ToolCallContainer, + ToolCallIdentifier, + ToolCallResource, + ToolCallResultOutcome, + ToolCallResultPagination, + ToolCallResultPolicy, + ToolCallResultResource, + ToolCallResultSecurity, + ToolCallResultSensitivity, } from "./contracts.js"; import { DEFAULT_FINISH_REASON, ExecuteToolCallArguments, ExecuteToolCallResult, MessageRole, + ToolCallAction, + ToolCallOutcomeStatus, + ToolPolicyDecision, } from "./contracts.js"; const EXECUTE_TOOL_SERIALIZATION_ERROR = @@ -32,6 +44,13 @@ function isTypedExecuteToolPayload( return value instanceof ExecuteToolCallArguments || value instanceof ExecuteToolCallResult; } +type JsonValue = null | boolean | number | string | JsonValue[] | JsonRecord; +type JsonRecord = { [key: string]: JsonValue }; + +const TOOL_CALL_ACTION_VALUES = new Set(Object.values(ToolCallAction)); +const TOOL_CALL_OUTCOME_STATUS_VALUES = new Set(Object.values(ToolCallOutcomeStatus)); +const TOOL_POLICY_DECISION_VALUES = new Set(Object.values(ToolPolicyDecision)); + /** * Type guard that returns `true` when the input is a structured wrapper * object (`InputMessages` or `OutputMessages`). @@ -134,12 +153,389 @@ export function serializeToolPayload(value: object | null | undefined): string | } try { - return JSON.stringify(value) ?? EXECUTE_TOOL_SERIALIZATION_ERROR; + return serializeTypedToolPayload(value); } catch { return EXECUTE_TOOL_SERIALIZATION_ERROR; } } +function serializeTypedToolPayload( + value: ExecuteToolCallArguments | ExecuteToolCallResult, +): string { + const stack = new Set(); + const serialized = + value instanceof ExecuteToolCallArguments + ? serializeArguments(value, stack) + : serializeResult(value, stack); + return JSON.stringify(serialized); +} + +function serializeArguments( + value: ExecuteToolCallArguments, + stack: Set, +): JsonRecord { + return withActiveContainer(value, stack, () => + withMetadata( + { + schema_version: toOptionalJsonValue(value.schema_version, stack), + resources: serializeOptionalSchemaArray(value.resources, serializeResource, stack), + action: validateOptionalEnum(value.action, TOOL_CALL_ACTION_VALUES, "action"), + parameters: serializeOptionalRecord(value.parameters, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResult(value: ExecuteToolCallResult, stack: Set): JsonRecord { + return withActiveContainer(value, stack, () => + withMetadata( + { + schema_version: toOptionalJsonValue(value.schema_version, stack), + outcome: serializeOptionalSchemaObject(value.outcome, serializeOutcome, stack), + resources: serializeOptionalSchemaArray( + value.resources, + serializeResultResource, + stack, + ), + data: serializeOptionalRecord(value.data, stack), + pagination: serializeOptionalSchemaObject( + value.pagination, + serializePagination, + stack, + ), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeIdentifier(value: ToolCallIdentifier, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + type: toOptionalJsonValue(value.type, stack), + value: toOptionalJsonValue(value.value, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeContainer(value: ToolCallContainer, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + id: toOptionalJsonValue(value.id, stack), + uri: toOptionalJsonValue(value.uri, stack), + type: toOptionalJsonValue(value.type, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResource(value: ToolCallResource, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + ...serializeResourceFields(value, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResultResource( + value: ToolCallResultResource, + stack: Set, +): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + ...serializeResourceFields(value, stack), + outcome: serializeOptionalSchemaObject(value.outcome, serializeOutcome, stack), + sensitivity: serializeOptionalSchemaObject( + value.sensitivity, + serializeSensitivity, + stack, + ), + policy: serializeOptionalSchemaObject(value.policy, serializePolicy, stack), + security: serializeOptionalSchemaObject(value.security, serializeSecurity, stack), + data: serializeOptionalRecord(value.data, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeResourceFields( + value: ToolCallResource, + stack: Set, +): Record { + return { + id: toOptionalJsonValue(value.id, stack), + uri: toOptionalJsonValue(value.uri, stack), + name: toOptionalJsonValue(value.name, stack), + type: toOptionalJsonValue(value.type, stack), + provider: toOptionalJsonValue(value.provider, stack), + identifiers: serializeOptionalSchemaArray( + value.identifiers, + serializeIdentifier, + stack, + ), + container: serializeOptionalSchemaObject(value.container, serializeContainer, stack), + }; +} + +function serializeOutcome(value: ToolCallResultOutcome, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + status: validateOptionalEnum( + value.status, + TOOL_CALL_OUTCOME_STATUS_VALUES, + "status", + ), + code: toOptionalJsonValue(value.code, stack), + provider_code: toOptionalJsonValue(value.provider_code, stack), + message: toOptionalJsonValue(value.message, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeSensitivity( + value: ToolCallResultSensitivity, + stack: Set, +): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { label_id: toOptionalJsonValue(value.label_id, stack) }, + value.extension_data, + stack, + ), + ); +} + +function serializePolicy(value: ToolCallResultPolicy, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + decision: validateOptionalEnum( + value.decision, + TOOL_POLICY_DECISION_VALUES, + "decision", + ), + id: toOptionalJsonValue(value.id, stack), + name: toOptionalJsonValue(value.name, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeSecurity(value: ToolCallResultSecurity, stack: Set): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { xpia_detected: toOptionalJsonValue(value.xpia_detected, stack) }, + value.extension_data, + stack, + ), + ); +} + +function serializePagination( + value: ToolCallResultPagination, + stack: Set, +): JsonRecord { + return serializeSchemaObject(value, stack, () => + withMetadata( + { + has_more: toOptionalJsonValue(value.has_more, stack), + next_cursor: toOptionalJsonValue(value.next_cursor, stack), + total_count: toOptionalJsonValue(value.total_count, stack), + }, + value.extension_data, + stack, + ), + ); +} + +function serializeSchemaObject( + value: T, + stack: Set, + serialize: () => JsonRecord, +): JsonRecord { + if (!isPlainRecord(value)) { + throw new TypeError("Execute tool schema values must be plain objects."); + } + return withActiveContainer(value, stack, serialize); +} + +function serializeOptionalSchemaObject( + value: T | null | undefined, + serialize: (item: T, stack: Set) => JsonRecord, + stack: Set, +): JsonRecord | undefined { + return value == null ? undefined : serialize(value, stack); +} + +function serializeOptionalSchemaArray( + value: T[] | null | undefined, + serialize: (item: T, stack: Set) => JsonRecord, + stack: Set, +): JsonValue[] | undefined { + if (value == null) { + return undefined; + } + if (!Array.isArray(value)) { + throw new TypeError("Execute tool schema collections must be arrays."); + } + return withActiveContainer(value, stack, () => value.map((item) => serialize(item, stack))); +} + +function serializeOptionalRecord( + value: Record | null | undefined, + stack: Set, +): JsonRecord | undefined { + return value == null ? undefined : toJsonRecord(value, stack); +} + +function withMetadata( + declared: Record, + extensionData: Record | null | undefined, + stack: Set, +): JsonRecord { + const serialized: JsonRecord = {}; + for (const [key, value] of Object.entries(declared)) { + if (value !== undefined) { + serialized[key] = value; + } + } + if (extensionData != null) { + if (!isPlainRecord(extensionData)) { + throw new TypeError("Execute tool extension data must be a plain object."); + } + if (Object.keys(extensionData).length > 0) { + serialized.metadata = toJsonRecord(extensionData, stack); + } + } + return serialized; +} + +function validateOptionalEnum( + value: unknown, + allowed: ReadonlySet, + field: string, +): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value !== "string" || !allowed.has(value)) { + throw new TypeError(`Invalid execute tool ${field}.`); + } + return value; +} + +function toOptionalJsonValue( + value: unknown, + stack: Set, +): JsonValue | undefined { + return value == null ? undefined : toJsonValue(value, stack); +} + +function toJsonValue(value: unknown, stack: Set): JsonValue { + if (value === null) { + return null; + } + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Execute tool payload numbers must be finite."); + } + return value; + } + if ( + value === undefined || + typeof value === "bigint" || + typeof value === "function" || + typeof value === "symbol" + ) { + throw new TypeError(`Unsupported execute tool payload value: ${typeof value}.`); + } + if (value instanceof Date) { + if (!Number.isFinite(value.getTime())) { + throw new TypeError("Execute tool payload dates must be valid."); + } + return value.toISOString(); + } + if (value instanceof Uint8Array) { + return Buffer.from(value).toString("base64"); + } + if (Array.isArray(value)) { + return withActiveContainer(value, stack, () => + value.map((item) => toJsonValue(item, stack)), + ); + } + if (value instanceof Set) { + return withActiveContainer(value, stack, () => + Array.from(value, (item) => toJsonValue(item, stack)), + ); + } + if (isPlainRecord(value)) { + return toJsonRecord(value, stack); + } + throw new TypeError( + `Unsupported execute tool payload object: ${value.constructor?.name ?? "unknown"}.`, + ); +} + +function toJsonRecord(value: Record, stack: Set): JsonRecord { + if (!isPlainRecord(value)) { + throw new TypeError("Execute tool payload mappings must be plain objects."); + } + return withActiveContainer(value, stack, () => { + const serialized: JsonRecord = {}; + for (const [key, item] of Object.entries(value)) { + serialized[key] = toJsonValue(item, stack); + } + return serialized; + }); +} + +function withActiveContainer( + value: object, + stack: Set, + serialize: () => T, +): T { + if (stack.has(value)) { + throw new TypeError("Circular reference detected in execute tool payload."); + } + stack.add(value); + try { + return serialize(); + } finally { + stack.delete(value); + } +} + +function isPlainRecord(value: object): value is Record { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + /** * Serializes system instruction parts to a JSON array. * diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index 52a77f1f..11b77d8e 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -10,6 +10,7 @@ import { Modality, ToolCallAction, ToolCallOutcomeStatus, + ToolPolicyDecision, } from "../../../../src/a365/contracts.js"; import type { InputMessages, OutputMessages } from "../../../../src/a365/contracts.js"; import { @@ -336,7 +337,7 @@ describe("serializeToolPayload", () => { expect(serializeToolPayload(null)).toBeUndefined(); }); - it("serializes typed payloads with schema version, nested values, and extension fields", () => { + it("serializes extension data as metadata without replacing declared fields", () => { const payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ, parameters: { @@ -348,23 +349,169 @@ describe("serializeToolPayload", () => { id: "doc-1", type: "document", provider: "sharepoint", - provider_resource_type: "page", + extension_data: { provider_resource_type: "page" }, }, ], - request_context: { scenario: "enterprise-search" }, + extension_data: { + action: "write", + schema_version: "9.9", + request_context: { scenario: "enterprise-search" }, + }, }); const serialized = serializeToolPayload(payload); const parsed = JSON.parse(serialized as string); - expect(parsed.schema_version).toBe("1.0"); - expect(parsed.action).toBe("read"); - expect(parsed.parameters.filters).toEqual({ - sensitivity: "high", - includeArchived: true, + expect(parsed).toEqual({ + schema_version: "1.0", + action: "read", + parameters: { + query: "GDPR", + filters: { sensitivity: "high", includeArchived: true }, + }, + resources: [ + { + id: "doc-1", + type: "document", + provider: "sharepoint", + metadata: { provider_resource_type: "page" }, + }, + ], + metadata: { + action: "write", + schema_version: "9.9", + request_context: { scenario: "enterprise-search" }, + }, + }); + }); + + it("keeps nested extension keys inside metadata", () => { + const payload = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + code: "ok", + extension_data: { code: "provider-code", status: "provider-status" }, + }, + resources: [ + { + policy: { + decision: ToolPolicyDecision.ALLOW, + extension_data: { decision: "conditional-allow" }, + }, + }, + ], + }); + + expect(JSON.parse(serializeToolPayload(payload) as string)).toEqual({ + schema_version: "1.0", + outcome: { + status: "success", + code: "ok", + metadata: { code: "provider-code", status: "provider-status" }, + }, + resources: [ + { + policy: { + decision: "allow", + metadata: { decision: "conditional-allow" }, + }, + }, + ], + }); + }); + + it("omits nullish declared fields and preserves nulls inside mappings and arrays", () => { + const payload = new ExecuteToolCallResult({ + outcome: { + status: ToolCallOutcomeStatus.SUCCESS, + provider_code: null as any, + extension_data: { provider_outcome: null, attempts: 0 }, + }, + data: { content: null, matches: [null, 1] }, + extension_data: { provider_result: null, cached: false }, + }); + + expect(JSON.parse(serializeToolPayload(payload) as string)).toEqual({ + schema_version: "1.0", + outcome: { + status: "success", + metadata: { provider_outcome: null, attempts: 0 }, + }, + data: { content: null, matches: [null, 1] }, + metadata: { provider_result: null, cached: false }, + }); + }); + + it.each([ + ["action", new ExecuteToolCallArguments({ action: "READ" as any })], + [ + "outcome status", + new ExecuteToolCallResult({ outcome: { status: "ok" as ToolCallOutcomeStatus } }), + ], + [ + "policy decision", + new ExecuteToolCallResult({ + resources: [{ policy: { decision: "permit" as ToolPolicyDecision } }], + }), + ], + ])("returns the exact fallback for an invalid %s", (_name, payload) => { + expect(serializeToolPayload(payload)).toBe(serializationError); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "returns the exact fallback for non-finite number %s", + (value) => { + expect(serializeToolPayload(new ExecuteToolCallResult({ data: { value } }))).toBe( + serializationError, + ); + }, + ); + + it.each([ + ["undefined", undefined], + ["function", () => "unsupported"], + ["symbol", Symbol("unsupported")], + ["bigint", BigInt(1)], + ])("returns the exact fallback for unsupported %s mapping values", (_name, value) => { + expect( + serializeToolPayload(new ExecuteToolCallArguments({ parameters: { value } })), + ).toBe(serializationError); + }); + + it("returns the exact fallback when extension data is not a mapping", () => { + expect( + serializeToolPayload( + new ExecuteToolCallArguments({ extension_data: [] as unknown as Record }), + ), + ).toBe(serializationError); + }); + + it("serializes supported JavaScript scalar and collection values", () => { + const payload = new ExecuteToolCallResult({ + data: { + timestamp: new Date("2026-01-02T03:04:05.000Z"), + bytes: new Uint8Array([0, 1, 2, 3]), + scopes: new Set(["read", "write"]), + }, + }); + + expect(JSON.parse(serializeToolPayload(payload) as string).data).toEqual({ + timestamp: "2026-01-02T03:04:05.000Z", + bytes: "AAECAw==", + scopes: ["read", "write"], + }); + }); + + it("serializes repeated references that are not cycles", () => { + const shared = { value: true }; + const payload = new ExecuteToolCallResult({ + data: { first: shared, second: shared }, + }); + + expect(JSON.parse(serializeToolPayload(payload) as string).data).toEqual({ + first: { value: true }, + second: { value: true }, }); - expect(parsed.resources[0].provider_resource_type).toBe("page"); - expect(parsed.request_context).toEqual({ scenario: "enterprise-search" }); }); it("returns the legacy fallback for circular generic payloads", () => { @@ -375,18 +522,23 @@ describe("serializeToolPayload", () => { }); it("returns the exact fallback for circular ExecuteToolCallArguments payloads", () => { - const payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ }); - payload.self = payload; + const extension_data: Record = {}; + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data, + }); + extension_data.self = payload; expect(serializeToolPayload(payload)).toBe(serializationError); }); it("returns the exact fallback for circular ExecuteToolCallResult payloads", () => { + const data: Record = { count: 1 }; const result = new ExecuteToolCallResult({ outcome: { status: ToolCallOutcomeStatus.SUCCESS }, - data: { count: 1 }, + data, }); - result.self = result; + data.self = data; expect(serializeToolPayload(result)).toBe(serializationError); }); @@ -394,16 +546,24 @@ describe("serializeToolPayload", () => { it("returns the exact fallback for bigint payloads", () => { expect( serializeToolPayload( - new ExecuteToolCallArguments({ action: ToolCallAction.READ, count: BigInt(1) }), + new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data: { count: BigInt(1) }, + }), ), ).toBe(serializationError); }); it("returns the exact fallback when payload serialization throws", () => { - const payload = new ExecuteToolCallArguments({ action: ToolCallAction.READ }); - payload.toJSON = () => { - throw new Error("boom"); + const extension_data = { + get value(): never { + throw new Error("boom"); + }, }; + const payload = new ExecuteToolCallArguments({ + action: ToolCallAction.READ, + extension_data, + }); expect(serializeToolPayload(payload)).toBe(serializationError); }); diff --git a/test/internal/unit/a365/scopes.test.ts b/test/internal/unit/a365/scopes.test.ts index 99991e35..91c79289 100644 --- a/test/internal/unit/a365/scopes.test.ts +++ b/test/internal/unit/a365/scopes.test.ts @@ -1560,10 +1560,10 @@ describe("Request content and message serialization (span attributes)", () => { id: "doc-1", type: "document", provider: "sharepoint", - provider_resource_type: "page", + extension_data: { provider_resource_type: "page" }, }, ], - request_context: { scenario: "enterprise-search" }, + extension_data: { request_context: { scenario: "enterprise-search" } }, }); const scope = ExecuteToolScope.start( @@ -1582,8 +1582,8 @@ describe("Request content and message serialization (span attributes)", () => { sensitivity: "high", includeArchived: true, }); - expect(parsed.resources[0].provider_resource_type).toBe("page"); - expect(parsed.request_context).toEqual({ scenario: "enterprise-search" }); + expect(parsed.resources[0].metadata.provider_resource_type).toBe("page"); + expect(parsed.metadata.request_context).toEqual({ scenario: "enterprise-search" }); }); it("should serialize typed results with nested outcome and extension fields", () => { @@ -1592,7 +1592,7 @@ describe("Request content and message serialization (span attributes)", () => { status: ToolCallOutcomeStatus.SUCCESS, message: "Fetched 1 document", provider_code: "OK", - retryable: false, + extension_data: { retryable: false }, }, resources: [ { @@ -1601,18 +1601,18 @@ describe("Request content and message serialization (span attributes)", () => { outcome: { status: ToolCallOutcomeStatus.SUCCESS, message: "available", - provider_status: "complete", + extension_data: { provider_status: "complete" }, }, data: { title: "Doc A" }, - relevance_score: 0.95, + extension_data: { relevance_score: 0.95 }, }, ], pagination: { has_more: false, total_count: 1, - request_charge: 3, + extension_data: { request_charge: 3 }, }, - source_trace: { provider: "sharepoint" }, + extension_data: { source_trace: { provider: "sharepoint" } }, }); const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); @@ -1627,16 +1627,16 @@ describe("Request content and message serialization (span attributes)", () => { status: "success", message: "Fetched 1 document", provider_code: "OK", - retryable: false, + metadata: { retryable: false }, }); - expect(parsed.resources[0].outcome.provider_status).toBe("complete"); - expect(parsed.resources[0].relevance_score).toBe(0.95); + expect(parsed.resources[0].outcome.metadata.provider_status).toBe("complete"); + expect(parsed.resources[0].metadata.relevance_score).toBe(0.95); expect(parsed.pagination).toEqual({ has_more: false, total_count: 1, - request_charge: 3, + metadata: { request_charge: 3 }, }); - expect(parsed.source_trace).toEqual({ provider: "sharepoint" }); + expect(parsed.metadata.source_trace).toEqual({ provider: "sharepoint" }); }); it("should omit the typed result attribute when response is undefined", () => { @@ -1676,11 +1676,13 @@ describe("Request content and message serialization (span attributes)", () => { }); it("should use the typed fallback for circular ExecuteToolCallArguments instances", () => { + const extension_data: Record = {}; const typedArgs = new ExecuteToolCallArguments({ action: ToolCallAction.READ, parameters: { query: "GDPR" }, + extension_data, }); - typedArgs.self = typedArgs; + extension_data.self = typedArgs; const scope = ExecuteToolScope.start( testRequest, @@ -1708,12 +1710,14 @@ describe("Request content and message serialization (span attributes)", () => { }); it("should use the typed fallback for circular ExecuteToolCallResult instances", () => { + const data: Record = {}; const typedResult = new ExecuteToolCallResult({ outcome: { status: ToolCallOutcomeStatus.SUCCESS, }, + data, }); - typedResult.self = typedResult; + data.self = data; const scope = ExecuteToolScope.start(testRequest, { toolName: "tool" }, testAgentDetails); scope.recordResponse(typedResult); From 929340fbd3a7db01f519a3fa7ece5d060a89c27a Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:11:47 -0600 Subject: [PATCH 08/11] docs(a365): document execute tool metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- A365_DOCUMENTATION.md | 15 ++-- CHANGELOG.md | 2 +- src/a365/message-utils.ts | 71 ++++--------------- .../unit/a365/executeToolJsonModels.test.ts | 5 +- test/internal/unit/a365/messageUtils.test.ts | 6 +- 5 files changed, 29 insertions(+), 70 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index b9cb1ca9..f90ed5aa 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -56,11 +56,11 @@ invokeScope.run(async () => { uri: "https://contoso.example/folders/1", type: "folder", }, - custom_resource_field: "kept", + extension_data: { custom_resource_field: "kept" }, }, ], parameters: { query: "hello", includeArchived: false }, - custom_argument_field: "kept", + extension_data: { custom_argument_field: "kept" }, }); const toolScope = ExecuteToolScope.start( @@ -102,11 +102,11 @@ invokeScope.run(async () => { name: "AllowDocumentRead", }, data: { snippetCount: 3 }, - custom_result_field: "kept", + extension_data: { custom_result_field: "kept" }, }, ], pagination: { has_more: false, total_count: 1 }, - custom_result_field: "kept", + extension_data: { custom_result_field: "kept" }, }), ); @@ -126,6 +126,13 @@ invokeScope.dispose(); `ExecuteToolScope` serializes arguments to `gen_ai.tool.call.arguments` and results to `gen_ai.tool.call.result` as JSON span attributes, so they may contain sensitive data. +Use `extension_data` for provider-specific fields on any typed ExecuteTool model. Non-empty +extension data is emitted under the model's `metadata` JSON property. Metadata keys remain +isolated from declared schema fields, so an `extension_data.action` or +`extension_data.schema_version` value cannot replace the typed `action` or `schema_version`. +Typed payloads that contain invalid enum tokens, non-finite numbers, unsupported values, or +reference cycles are replaced with +`{"serialization_error":"Failed to serialize execute tool payload."}`. `InvokeAgentScope`, `InferenceScope`, and `ExecuteToolScope` accept `request.sessionId`. When you provide it, those scopes write `microsoft.session.id` directly on the created diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a7b9e4..1229eab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Default `InvokeAgentScope` spans to `SpanKind.INTERNAL` while preserving explicit span-kind overrides. [#241](https://github.com/microsoft/opentelemetry-distro-javascript/pull/241) ### Features Added -- Add typed, extensible ExecuteTool argument and result schemas with default schema_version: "1.0". [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) +- Add typed ExecuteTool argument and result schemas with default schema_version: "1.0", collision-safe `extension_data` emitted under `metadata`, and non-throwing validation aligned with the .NET and Python distros. [#240](https://github.com/microsoft/opentelemetry-distro-javascript/pull/240) - Add manual `sessionId` propagation to `ExecuteToolScope` and `InferenceScope`, plus opt-in custom baggage enrichment for recognized GenAI spans through `BaggageBuilder.customAttribute()` and `customAttributes()`. [#242](https://github.com/microsoft/opentelemetry-distro-javascript/pull/242) - Add GenAI v1.42 InvokeAgent request, response, cache-token, and provider attribute capture for manual A365 scopes. [#239](https://github.com/microsoft/opentelemetry-distro-javascript/pull/239) diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts index 142cadee..ded3fe72 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -170,10 +170,7 @@ function serializeTypedToolPayload( return JSON.stringify(serialized); } -function serializeArguments( - value: ExecuteToolCallArguments, - stack: Set, -): JsonRecord { +function serializeArguments(value: ExecuteToolCallArguments, stack: Set): JsonRecord { return withActiveContainer(value, stack, () => withMetadata( { @@ -194,17 +191,9 @@ function serializeResult(value: ExecuteToolCallResult, stack: Set): Json { schema_version: toOptionalJsonValue(value.schema_version, stack), outcome: serializeOptionalSchemaObject(value.outcome, serializeOutcome, stack), - resources: serializeOptionalSchemaArray( - value.resources, - serializeResultResource, - stack, - ), + resources: serializeOptionalSchemaArray(value.resources, serializeResultResource, stack), data: serializeOptionalRecord(value.data, stack), - pagination: serializeOptionalSchemaObject( - value.pagination, - serializePagination, - stack, - ), + pagination: serializeOptionalSchemaObject(value.pagination, serializePagination, stack), }, value.extension_data, stack, @@ -251,20 +240,13 @@ function serializeResource(value: ToolCallResource, stack: Set): JsonRec ); } -function serializeResultResource( - value: ToolCallResultResource, - stack: Set, -): JsonRecord { +function serializeResultResource(value: ToolCallResultResource, stack: Set): JsonRecord { return serializeSchemaObject(value, stack, () => withMetadata( { ...serializeResourceFields(value, stack), outcome: serializeOptionalSchemaObject(value.outcome, serializeOutcome, stack), - sensitivity: serializeOptionalSchemaObject( - value.sensitivity, - serializeSensitivity, - stack, - ), + sensitivity: serializeOptionalSchemaObject(value.sensitivity, serializeSensitivity, stack), policy: serializeOptionalSchemaObject(value.policy, serializePolicy, stack), security: serializeOptionalSchemaObject(value.security, serializeSecurity, stack), data: serializeOptionalRecord(value.data, stack), @@ -285,11 +267,7 @@ function serializeResourceFields( name: toOptionalJsonValue(value.name, stack), type: toOptionalJsonValue(value.type, stack), provider: toOptionalJsonValue(value.provider, stack), - identifiers: serializeOptionalSchemaArray( - value.identifiers, - serializeIdentifier, - stack, - ), + identifiers: serializeOptionalSchemaArray(value.identifiers, serializeIdentifier, stack), container: serializeOptionalSchemaObject(value.container, serializeContainer, stack), }; } @@ -298,11 +276,7 @@ function serializeOutcome(value: ToolCallResultOutcome, stack: Set): Jso return serializeSchemaObject(value, stack, () => withMetadata( { - status: validateOptionalEnum( - value.status, - TOOL_CALL_OUTCOME_STATUS_VALUES, - "status", - ), + status: validateOptionalEnum(value.status, TOOL_CALL_OUTCOME_STATUS_VALUES, "status"), code: toOptionalJsonValue(value.code, stack), provider_code: toOptionalJsonValue(value.provider_code, stack), message: toOptionalJsonValue(value.message, stack), @@ -313,10 +287,7 @@ function serializeOutcome(value: ToolCallResultOutcome, stack: Set): Jso ); } -function serializeSensitivity( - value: ToolCallResultSensitivity, - stack: Set, -): JsonRecord { +function serializeSensitivity(value: ToolCallResultSensitivity, stack: Set): JsonRecord { return serializeSchemaObject(value, stack, () => withMetadata( { label_id: toOptionalJsonValue(value.label_id, stack) }, @@ -330,11 +301,7 @@ function serializePolicy(value: ToolCallResultPolicy, stack: Set): JsonR return serializeSchemaObject(value, stack, () => withMetadata( { - decision: validateOptionalEnum( - value.decision, - TOOL_POLICY_DECISION_VALUES, - "decision", - ), + decision: validateOptionalEnum(value.decision, TOOL_POLICY_DECISION_VALUES, "decision"), id: toOptionalJsonValue(value.id, stack), name: toOptionalJsonValue(value.name, stack), }, @@ -354,10 +321,7 @@ function serializeSecurity(value: ToolCallResultSecurity, stack: Set): J ); } -function serializePagination( - value: ToolCallResultPagination, - stack: Set, -): JsonRecord { +function serializePagination(value: ToolCallResultPagination, stack: Set): JsonRecord { return serializeSchemaObject(value, stack, () => withMetadata( { @@ -447,10 +411,7 @@ function validateOptionalEnum( return value; } -function toOptionalJsonValue( - value: unknown, - stack: Set, -): JsonValue | undefined { +function toOptionalJsonValue(value: unknown, stack: Set): JsonValue | undefined { return value == null ? undefined : toJsonValue(value, stack); } @@ -485,9 +446,7 @@ function toJsonValue(value: unknown, stack: Set): JsonValue { return Buffer.from(value).toString("base64"); } if (Array.isArray(value)) { - return withActiveContainer(value, stack, () => - value.map((item) => toJsonValue(item, stack)), - ); + return withActiveContainer(value, stack, () => value.map((item) => toJsonValue(item, stack))); } if (value instanceof Set) { return withActiveContainer(value, stack, () => @@ -515,11 +474,7 @@ function toJsonRecord(value: Record, stack: Set): JsonR }); } -function withActiveContainer( - value: object, - stack: Set, - serialize: () => T, -): T { +function withActiveContainer(value: object, stack: Set, serialize: () => T): T { if (stack.has(value)) { throw new TypeError("Circular reference detected in execute tool payload."); } diff --git a/test/internal/unit/a365/executeToolJsonModels.test.ts b/test/internal/unit/a365/executeToolJsonModels.test.ts index 67e764f3..9bc3cf9d 100644 --- a/test/internal/unit/a365/executeToolJsonModels.test.ts +++ b/test/internal/unit/a365/executeToolJsonModels.test.ts @@ -5,10 +5,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import * as a365 from "../../../../src/a365/index.js"; import * as rootExports from "../../../../src/index.js"; -import type { - ToolCallDetails, - ToolCallExtensionData, -} from "../../../../src/a365/index.js"; +import type { ToolCallDetails, ToolCallExtensionData } from "../../../../src/a365/index.js"; import type { ToolCallExtensionData as RootToolCallExtensionData } from "../../../../src/index.js"; describe("execute tool JSON models", () => { diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index 11b77d8e..7c9ae530 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -473,9 +473,9 @@ describe("serializeToolPayload", () => { ["symbol", Symbol("unsupported")], ["bigint", BigInt(1)], ])("returns the exact fallback for unsupported %s mapping values", (_name, value) => { - expect( - serializeToolPayload(new ExecuteToolCallArguments({ parameters: { value } })), - ).toBe(serializationError); + expect(serializeToolPayload(new ExecuteToolCallArguments({ parameters: { value } }))).toBe( + serializationError, + ); }); it("returns the exact fallback when extension data is not a mapping", () => { From 4b7bdff9454ebf9b4b5586ecb71fb6e1b6173139 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:20:25 -0600 Subject: [PATCH 09/11] fix(a365): harden execute tool serialization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- .github/workflows/pr-validation.yml | 3 -- package.json | 1 - src/a365/message-utils.ts | 39 +++++++++++--- src/a365/tool-call-models.ts | 12 ++++- .../functional/esmBuildImport.test.ts | 50 +++++++++++++++++ .../unit/a365/executeToolJsonModels.test.ts | 8 +++ test/internal/unit/a365/messageUtils.test.ts | 54 +++++++++++++++++++ tsconfig.type-tests.json | 7 --- 8 files changed, 154 insertions(+), 20 deletions(-) delete mode 100644 tsconfig.type-tests.json diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 1ff57a4a..32a41d56 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -27,9 +27,6 @@ jobs: - name: Build run: npm run build - - name: Type-check test sources - run: npm run typecheck:test - - name: Format check run: npm run format diff --git a/package.json b/package.json index 60a85055..f6edea07 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "test:unit": "vitest run --config vitest.unit.config.ts", "test:functional": "vitest run --config vitest.functional.config.ts", "test:integration": "node --test test/integration/*.test.mjs", - "typecheck:test": "tsc -p tsconfig.type-tests.json --noEmit", "test:performance": "node --expose-gc perf/benchmark.mjs", "test:esm-build": "node --input-type=module -e \"import('./dist/esm/distro/instrumentations.js').then(()=>console.log('esm import ok')).catch((e)=>{console.error(e);process.exit(1);})\"", "test:watch": "vitest", diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts index ded3fe72..ef52deff 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -34,6 +34,7 @@ import { ToolCallOutcomeStatus, ToolPolicyDecision, } from "./contracts.js"; +import { EXECUTE_TOOL_PAYLOAD_KIND } from "./tool-call-models.js"; const EXECUTE_TOOL_SERIALIZATION_ERROR = '{"serialization_error":"Failed to serialize execute tool payload."}'; @@ -41,7 +42,10 @@ const EXECUTE_TOOL_SERIALIZATION_ERROR = function isTypedExecuteToolPayload( value: object, ): value is ExecuteToolCallArguments | ExecuteToolCallResult { - return value instanceof ExecuteToolCallArguments || value instanceof ExecuteToolCallResult; + const kind = (value as Partial>)[ + EXECUTE_TOOL_PAYLOAD_KIND + ]; + return kind === "arguments" || kind === "result"; } type JsonValue = null | boolean | number | string | JsonValue[] | JsonRecord; @@ -164,7 +168,7 @@ function serializeTypedToolPayload( ): string { const stack = new Set(); const serialized = - value instanceof ExecuteToolCallArguments + value[EXECUTE_TOOL_PAYLOAD_KIND] === "arguments" ? serializeArguments(value, stack) : serializeResult(value, stack); return JSON.stringify(serialized); @@ -365,7 +369,7 @@ function serializeOptionalSchemaArray( if (!Array.isArray(value)) { throw new TypeError("Execute tool schema collections must be arrays."); } - return withActiveContainer(value, stack, () => value.map((item) => serialize(item, stack))); + return serializeArray(value, stack, (item) => serialize(item, stack)); } function serializeOptionalRecord( @@ -390,8 +394,9 @@ function withMetadata( if (!isPlainRecord(extensionData)) { throw new TypeError("Execute tool extension data must be a plain object."); } - if (Object.keys(extensionData).length > 0) { - serialized.metadata = toJsonRecord(extensionData, stack); + const metadata = toJsonRecord(extensionData, stack); + if (Object.keys(metadata).length > 0) { + serialized.metadata = metadata; } } return serialized; @@ -446,7 +451,7 @@ function toJsonValue(value: unknown, stack: Set): JsonValue { return Buffer.from(value).toString("base64"); } if (Array.isArray(value)) { - return withActiveContainer(value, stack, () => value.map((item) => toJsonValue(item, stack))); + return serializeArray(value, stack, (item) => toJsonValue(item, stack)); } if (value instanceof Set) { return withActiveContainer(value, stack, () => @@ -465,8 +470,11 @@ function toJsonRecord(value: Record, stack: Set): JsonR if (!isPlainRecord(value)) { throw new TypeError("Execute tool payload mappings must be plain objects."); } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError("Execute tool payload mappings must use string keys."); + } return withActiveContainer(value, stack, () => { - const serialized: JsonRecord = {}; + const serialized = Object.create(null) as JsonRecord; for (const [key, item] of Object.entries(value)) { serialized[key] = toJsonValue(item, stack); } @@ -474,6 +482,23 @@ function toJsonRecord(value: Record, stack: Set): JsonR }); } +function serializeArray( + value: T[], + stack: Set, + serialize: (item: T) => JsonValue, +): JsonValue[] { + return withActiveContainer(value, stack, () => { + const serialized: JsonValue[] = []; + for (let index = 0; index < value.length; index++) { + if (!(index in value)) { + throw new TypeError("Execute tool payload arrays must not be sparse."); + } + serialized.push(serialize(value[index])); + } + return serialized; + }); +} + function withActiveContainer(value: object, stack: Set, serialize: () => T): T { if (stack.has(value)) { throw new TypeError("Circular reference detected in execute tool payload."); diff --git a/src/a365/tool-call-models.ts b/src/a365/tool-call-models.ts index df62761e..bedc6d53 100644 --- a/src/a365/tool-call-models.ts +++ b/src/a365/tool-call-models.ts @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export const EXECUTE_TOOL_PAYLOAD_KIND = Symbol.for( + "@microsoft/opentelemetry.execute_tool_payload_kind", +); + /** Action requested by an execute tool call. */ export enum ToolCallAction { /** Create a resource. */ @@ -131,6 +135,7 @@ export interface ToolCallResultResource extends ToolCallResource { /** Structured arguments for an execute tool call. */ export class ExecuteToolCallArguments implements ToolCallExtensionData { + declare readonly [EXECUTE_TOOL_PAYLOAD_KIND]: "arguments"; /** Schema version for this payload. */ declare schema_version: string; /** Resources referenced by the tool call. */ @@ -143,7 +148,8 @@ export class ExecuteToolCallArguments implements ToolCallExtensionData { declare extension_data?: Record; constructor(init: Partial = {}) { - this.schema_version = init.schema_version ?? "1.0"; + Object.defineProperty(this, EXECUTE_TOOL_PAYLOAD_KIND, { value: "arguments" }); + this.schema_version = init.schema_version === undefined ? "1.0" : init.schema_version; if (init.resources !== undefined) this.resources = init.resources; if (init.action !== undefined) this.action = init.action; if (init.parameters !== undefined) this.parameters = init.parameters; @@ -153,6 +159,7 @@ export class ExecuteToolCallArguments implements ToolCallExtensionData { /** Structured result for an execute tool call. */ export class ExecuteToolCallResult implements ToolCallExtensionData { + declare readonly [EXECUTE_TOOL_PAYLOAD_KIND]: "result"; /** Schema version for this payload. */ declare schema_version: string; /** Overall tool call outcome. */ @@ -167,7 +174,8 @@ export class ExecuteToolCallResult implements ToolCallExtensionData { declare extension_data?: Record; constructor(init: Partial = {}) { - this.schema_version = init.schema_version ?? "1.0"; + Object.defineProperty(this, EXECUTE_TOOL_PAYLOAD_KIND, { value: "result" }); + this.schema_version = init.schema_version === undefined ? "1.0" : init.schema_version; if (init.outcome !== undefined) this.outcome = init.outcome; if (init.resources !== undefined) this.resources = init.resources; if (init.data !== undefined) this.data = init.data; diff --git a/test/internal/functional/esmBuildImport.test.ts b/test/internal/functional/esmBuildImport.test.ts index 489902ec..70163718 100644 --- a/test/internal/functional/esmBuildImport.test.ts +++ b/test/internal/functional/esmBuildImport.test.ts @@ -2,7 +2,9 @@ // Licensed under the MIT License. import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; describe("ESM build import regression", () => { @@ -16,4 +18,52 @@ describe("ESM build import regression", () => { await expect(import(modulePath)).resolves.toBeDefined(); }); + + it("recognizes typed execute tool models across ESM and CommonJS builds", async () => { + const esmModelsPath = resolve(process.cwd(), "dist/esm/a365/tool-call-models.js"); + const esmMessageUtilsPath = resolve(process.cwd(), "dist/esm/a365/message-utils.js"); + const cjsModelsPath = resolve(process.cwd(), "dist/commonjs/a365/tool-call-models.js"); + const cjsMessageUtilsPath = resolve(process.cwd(), "dist/commonjs/a365/message-utils.js"); + + if ( + !existsSync(esmModelsPath) || + !existsSync(esmMessageUtilsPath) || + !existsSync(cjsModelsPath) || + !existsSync(cjsMessageUtilsPath) + ) { + return; + } + + const require = createRequire(import.meta.url); + const esmModels = await import(pathToFileURL(esmModelsPath).href); + const esmMessageUtils = await import(pathToFileURL(esmMessageUtilsPath).href); + const cjsModels = require(cjsModelsPath); + const cjsMessageUtils = require(cjsMessageUtilsPath); + const expected = { + schema_version: "1.0", + action: "read", + metadata: { action: "write" }, + }; + + expect( + JSON.parse( + esmMessageUtils.serializeToolPayload( + new cjsModels.ExecuteToolCallArguments({ + action: cjsModels.ToolCallAction.READ, + extension_data: { action: "write" }, + }), + ), + ), + ).toEqual(expected); + expect( + JSON.parse( + cjsMessageUtils.serializeToolPayload( + new esmModels.ExecuteToolCallArguments({ + action: esmModels.ToolCallAction.READ, + extension_data: { action: "write" }, + }), + ), + ), + ).toEqual(expected); + }); }); diff --git a/test/internal/unit/a365/executeToolJsonModels.test.ts b/test/internal/unit/a365/executeToolJsonModels.test.ts index 9bc3cf9d..57d25afc 100644 --- a/test/internal/unit/a365/executeToolJsonModels.test.ts +++ b/test/internal/unit/a365/executeToolJsonModels.test.ts @@ -89,6 +89,14 @@ describe("execute tool JSON models", () => { expect(explicitArgs.schema_version).toBe("2.0"); }); + it("preserves explicit null schema versions for omission during serialization", () => { + const argumentsModel = new a365.ExecuteToolCallArguments({ schema_version: null as any }); + const resultModel = new a365.ExecuteToolCallResult({ schema_version: null as any }); + + expect(argumentsModel.schema_version).toBeNull(); + expect(resultModel.schema_version).toBeNull(); + }); + it("allows execute tool call argument models in ToolCallDetails.arguments", () => { const argumentsModel = new a365.ExecuteToolCallArguments({ action: a365.ToolCallAction.READ, diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index 7c9ae530..eebce03b 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -442,6 +442,38 @@ describe("serializeToolPayload", () => { }); }); + it("omits explicit null schema versions", () => { + const payload = new ExecuteToolCallArguments({ schema_version: null as any }); + + expect(JSON.parse(serializeToolPayload(payload) as string)).toEqual({}); + }); + + it("preserves __proto__ as an own mapping key", () => { + const data = Object.create(null) as Record; + Object.defineProperty(data, "__proto__", { + value: { provider: "graph" }, + enumerable: true, + }); + data.kept = 1; + + const serialized = serializeToolPayload( + new ExecuteToolCallResult({ + data, + extension_data: data, + }), + ); + + const parsed = JSON.parse(serialized as string); + + expect(parsed.schema_version).toBe("1.0"); + expect(parsed.data.kept).toBe(1); + expect(parsed.metadata.kept).toBe(1); + expect(Object.hasOwn(parsed.data, "__proto__")).toBe(true); + expect(Object.hasOwn(parsed.metadata, "__proto__")).toBe(true); + expect(parsed.data["__proto__"]).toEqual({ provider: "graph" }); + expect(parsed.metadata["__proto__"]).toEqual({ provider: "graph" }); + }); + it.each([ ["action", new ExecuteToolCallArguments({ action: "READ" as any })], [ @@ -478,6 +510,28 @@ describe("serializeToolPayload", () => { ); }); + it("returns the exact fallback for sparse arrays", () => { + const values = new Array(2); + values[1] = 1; + + expect(serializeToolPayload(new ExecuteToolCallResult({ data: { values } }))).toBe( + serializationError, + ); + }); + + it("returns the exact fallback for symbol-keyed mappings", () => { + const data = { kept: true }; + Object.defineProperty(data, Symbol("unsupported"), { + value: "dropped", + enumerable: true, + }); + + expect(serializeToolPayload(new ExecuteToolCallResult({ data }))).toBe(serializationError); + expect(serializeToolPayload(new ExecuteToolCallResult({ extension_data: data }))).toBe( + serializationError, + ); + }); + it("returns the exact fallback when extension data is not a mapping", () => { expect( serializeToolPayload( diff --git a/tsconfig.type-tests.json b/tsconfig.type-tests.json deleted file mode 100644 index 41ee652d..00000000 --- a/tsconfig.type-tests.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.test.json", - "compilerOptions": { - "types": ["node", "vitest/globals"] - }, - "include": ["src", "test/internal/unit/a365/executeToolJsonModels.test.ts"] -} From f6a2b820e6e70bcb14d9a7b59f6b560d83088e02 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:24:13 -0600 Subject: [PATCH 10/11] fix(a365): reject inherited sparse array entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- src/a365/message-utils.ts | 2 +- test/internal/unit/a365/messageUtils.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/a365/message-utils.ts b/src/a365/message-utils.ts index ef52deff..9dae878f 100644 --- a/src/a365/message-utils.ts +++ b/src/a365/message-utils.ts @@ -490,7 +490,7 @@ function serializeArray( return withActiveContainer(value, stack, () => { const serialized: JsonValue[] = []; for (let index = 0; index < value.length; index++) { - if (!(index in value)) { + if (!Object.hasOwn(value, index)) { throw new TypeError("Execute tool payload arrays must not be sparse."); } serialized.push(serialize(value[index])); diff --git a/test/internal/unit/a365/messageUtils.test.ts b/test/internal/unit/a365/messageUtils.test.ts index eebce03b..34918d42 100644 --- a/test/internal/unit/a365/messageUtils.test.ts +++ b/test/internal/unit/a365/messageUtils.test.ts @@ -519,6 +519,15 @@ describe("serializeToolPayload", () => { ); }); + it("returns the exact fallback when a sparse array inherits an indexed value", () => { + const values = new Array(1); + Object.setPrototypeOf(values, { 0: "inherited" }); + + expect(serializeToolPayload(new ExecuteToolCallResult({ data: { values } }))).toBe( + serializationError, + ); + }); + it("returns the exact fallback for symbol-keyed mappings", () => { const data = { kept: true }; Object.defineProperty(data, Symbol("unsupported"), { From 8a9ceb2d4d37c4e5c831751bf09d6d72f224921b Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 29 Sep 2026 16:28:04 -0600 Subject: [PATCH 11/11] chore: remove superpowers planning docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5a6fe74f-97a4-4cf9-921f-2c597ee94adf --- .../2026-09-29-execute-tool-schema-parity.md | 240 ------------------ ...09-29-execute-tool-schema-parity-design.md | 48 ---- 2 files changed, 288 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md delete mode 100644 docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md diff --git a/docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md b/docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md deleted file mode 100644 index 8c43fb22..00000000 --- a/docs/superpowers/plans/2026-09-29-execute-tool-schema-parity.md +++ /dev/null @@ -1,240 +0,0 @@ -# ExecuteTool Schema Parity Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make typed ExecuteTool models and serialization match the current .NET and Python collision, metadata, validation, and failure behavior. - -**Architecture:** Typed models expose `extension_data`, which the serializer emits under `metadata` instead of flattening into declared fields. A schema-aware recursive converter validates enum fields and JSON-compatible values before one final `JSON.stringify`, while legacy raw payloads keep the existing path. - -**Tech Stack:** TypeScript, Vitest, OpenTelemetry JavaScript API, npm scripts. - -## Global Constraints - -- Preserve existing raw `Record` and JSON string payload behavior. -- Emit the exact diagnostic JSON `{"serialization_error":"Failed to serialize execute tool payload."}` for every typed serialization failure. -- Omit nullish declared model fields and preserve explicit nulls inside mappings and arrays. -- Add no dependencies. - ---- - -### Task 1: Public extension-data model contract - -**Files:** -- Modify: `src/a365/tool-call-models.ts` -- Modify: `test/internal/unit/a365/executeToolJsonModels.test.ts` - -**Interfaces:** -- Produces: `extension_data?: Record` on every ExecuteTool schema model. -- Produces: `ExecuteToolCallArguments` and `ExecuteToolCallResult` constructors that copy only declared properties. - -- [ ] **Step 1: Write failing model tests** - -Add tests constructing top-level and nested models with: - -```ts -const payload = new a365.ExecuteToolCallArguments({ - action: a365.ToolCallAction.READ, - extension_data: { action: "write", schema_version: "9.9" }, -}); - -expect(payload.action).toBe("read"); -expect(payload.schema_version).toBe("1.0"); -expect(payload.extension_data).toEqual({ action: "write", schema_version: "9.9" }); -``` - -Update existing custom-field fixtures to use `extension_data`. - -- [ ] **Step 2: Run the focused test and confirm failure** - -Run: `npm run test:unit -- test/internal/unit/a365/executeToolJsonModels.test.ts` - -Expected: FAIL because `extension_data` is not the established extensibility contract and current constructors copy arbitrary keys. - -- [ ] **Step 3: Implement the explicit model contract** - -Replace open index signatures with: - -```ts -export interface ToolCallExtensionData { - extension_data?: Record; -} -``` - -Extend each nested interface from `ToolCallExtensionData`. Make both top-level classes implement it and explicitly assign only `schema_version`, declared fields, and `extension_data` in their constructors. - -- [ ] **Step 4: Run the focused test** - -Run: `npm run test:unit -- test/internal/unit/a365/executeToolJsonModels.test.ts` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```powershell -git add src\a365\tool-call-models.ts test\internal\unit\a365\executeToolJsonModels.test.ts -git commit -m "fix(a365): isolate execute tool extension data" -``` - -### Task 2: Strict typed-payload serializer - -**Files:** -- Modify: `src/a365/message-utils.ts` -- Modify: `test/internal/unit/a365/messageUtils.test.ts` - -**Interfaces:** -- Consumes: `extension_data?: Record` from Task 1. -- Produces: `serializeToolPayload(value: object | null | undefined): string | undefined`. -- Produces internal schema converters for arguments, results, resources, outcomes, pagination, policies, and generic mapping values. - -- [ ] **Step 1: Write failing collision and wire-shape tests** - -Add assertions equivalent to: - -```ts -const payload = new ExecuteToolCallArguments({ - action: ToolCallAction.READ, - extension_data: { action: "write", schema_version: "9.9" }, -}); - -expect(JSON.parse(serializeToolPayload(payload)!)).toEqual({ - schema_version: "1.0", - action: "read", - metadata: { action: "write", schema_version: "9.9" }, -}); -``` - -Add a nested outcome collision test where declared `code: "ok"` and `extension_data.code: "provider-code"` remain separate. - -- [ ] **Step 2: Write failing validation and null-semantics tests** - -Cover: - -```ts -expect(serializeToolPayload(new ExecuteToolCallArguments({ action: "READ" as any }))) - .toBe(EXPECTED_ERROR_JSON); -expect(serializeToolPayload(new ExecuteToolCallResult({ data: { score: Number.NaN } }))) - .toBe(EXPECTED_ERROR_JSON); -expect(JSON.parse(serializeToolPayload(new ExecuteToolCallResult({ - outcome: { status: ToolCallOutcomeStatus.SUCCESS, provider_code: null as any, - extension_data: { provider_outcome: null } }, - data: { content: null, matches: [null, 1] }, -}))!)).toEqual({ - schema_version: "1.0", - outcome: { status: "success", metadata: { provider_outcome: null } }, - data: { content: null, matches: [null, 1] }, -}); -``` - -Also cover `Infinity`, functions, `undefined` mapping values, `bigint`, cycles, repeated references, `Date`, `Uint8Array`, and `Set`. - -- [ ] **Step 3: Run focused tests and confirm failure** - -Run: `npm run test:unit -- test/internal/unit/a365/messageUtils.test.ts` - -Expected: FAIL because native `JSON.stringify` flattens current model fields and silently coerces invalid values. - -- [ ] **Step 4: Implement schema-aware conversion** - -Add internal helpers with these responsibilities: - -```ts -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; - -function serializeTypedToolPayload( - value: ExecuteToolCallArguments | ExecuteToolCallResult, -): string; - -function toJsonValue(value: unknown, stack: Set): JsonValue; -function toJsonRecord(value: Record, stack: Set): Record; -function withMetadata( - declared: Record, - extensionData: Record | undefined, - stack: Set, -): Record; -function validateEnum(value: unknown, allowed: ReadonlySet, field: string): string; -``` - -Use explicit converters for every schema interface. Omit nullish declared fields, convert non-empty `extension_data` to `metadata`, preserve mapping nulls, reject unsupported/coerced values, and remove containers from the active stack in `finally` so repeated references remain valid. - -- [ ] **Step 5: Run focused tests** - -Run: `npm run test:unit -- test/internal/unit/a365/messageUtils.test.ts` - -Expected: PASS. - -- [ ] **Step 6: Run both ExecuteTool unit files** - -Run: `npm run test:unit -- test/internal/unit/a365/executeToolJsonModels.test.ts test/internal/unit/a365/messageUtils.test.ts` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -```powershell -git add src\a365\message-utils.ts test\internal\unit\a365\messageUtils.test.ts -git commit -m "fix(a365): validate typed execute tool payloads" -``` - -### Task 3: Documentation and repository validation - -**Files:** -- Modify: `A365_DOCUMENTATION.md` -- Modify: `CHANGELOG.md` only if the existing entry describes flattened custom fields. - -**Interfaces:** -- Documents: `extension_data` construction API and emitted `metadata` JSON. - -- [ ] **Step 1: Update documentation examples** - -Replace direct custom properties with: - -```ts -extension_data: { - provider_trace_id: "trace-789", -} -``` - -Show that the emitted JSON contains: - -```json -"metadata": { - "provider_trace_id": "trace-789" -} -``` - -State that metadata keys may match declared field names without replacing those declared fields. - -- [ ] **Step 2: Run formatting** - -Run: `npm run format` - -Expected: exits 0. - -- [ ] **Step 3: Run type checks, build, lint, and unit tests** - -Run: `npm run typecheck:test && npm run build && npm run lint && npm run test:unit` - -Expected: all commands exit 0; lint may report only repository-baseline warnings. - -- [ ] **Step 4: Run remaining PR validation suites** - -Run: `npm run test:functional && npm run test:esm-build` - -Expected: all commands exit 0. - -- [ ] **Step 5: Inspect and commit final changes** - -Run: `git diff --check && git status --short` - -Expected: no whitespace errors and only intended files changed. - -```powershell -git add A365_DOCUMENTATION.md CHANGELOG.md -git commit -m "docs(a365): document execute tool metadata" -``` - -- [ ] **Step 6: Push the PR branch** - -Run: `git push origin feature/a365-execute-tool-schemas` - -Expected: the remote PR head updates successfully and CI starts. diff --git a/docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md b/docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md deleted file mode 100644 index 9a7dfb06..00000000 --- a/docs/superpowers/specs/2026-09-29-execute-tool-schema-parity-design.md +++ /dev/null @@ -1,48 +0,0 @@ -# ExecuteTool Schema Parity Design - -## Goal - -Align the JavaScript typed ExecuteTool payload experience with the current .NET and Python implementations while preserving the existing behavior for legacy raw object and string payloads. - -## Alternatives considered - -1. **Explicit extension data serialized as `metadata` (selected).** Declared schema fields remain authoritative, provider fields cannot collide with them, and the wire shape matches Python and the latest .NET POCO models. -2. **Flatten extension data and reject collisions.** This prevents overwrites but produces a different wire contract from the other SDKs. -3. **Keep flat last-write-wins properties.** This preserves the current PR implementation but permits silent schema-field replacement and does not fix the reported issue. - -## Model contract - -Every typed schema model exposes an optional `extension_data` mapping. During serialization, a non-empty mapping is emitted as `metadata`. Extension entries are never merged into the model object, so keys such as `action`, `schema_version`, `status`, and `code` remain isolated inside `metadata`. - -Declared model properties that are `null` or `undefined` are omitted. Empty arrays, empty objects, `false`, zero, and empty strings are preserved. Values inside caller-provided mappings and arrays preserve explicit `null`. - -## Serialization - -Typed ExecuteTool models use a dedicated recursive serializer rather than native `JSON.stringify` directly: - -- validate `action`, outcome `status`, and policy `decision` against their public enum tokens; -- reject non-finite numbers, `bigint`, functions, symbols, unsupported object instances, and cyclic references; -- preserve repeated non-cyclic references; -- serialize `Date` values as ISO-8601 strings, byte arrays as base64, sets as arrays, and enum members as their string values; -- require extension data and nested mappings to be ordinary string-keyed records; -- return the existing diagnostic JSON for any typed-payload serialization failure. - -Legacy untyped payloads continue through `safeSerializeToJson` unchanged. - -## Components - -- `tool-call-models.ts` defines the public `extension_data` fields and removes the open index signatures that currently allow undeclared fields to collide with schema properties. -- `message-utils.ts` owns schema-aware conversion and safe typed-payload serialization. -- Unit tests cover top-level and nested collisions, metadata omission, null semantics, enum validation, non-finite numbers, unsupported values, cycles, repeated references, and ordinary successful payloads. -- Documentation examples use `extension_data` and show the emitted `metadata` wire shape. - -## Compatibility - -The feature is not yet released, so correcting the typed model construction API does not break an existing published contract. Existing raw `Record` and JSON string ToolCallDetails payloads retain their current behavior. - -## Success criteria - -- JavaScript collision behavior and wire output match .NET and Python. -- A regression test fails if extension keys are flattened or can replace declared fields. -- Typed serialization never throws and never silently coerces invalid schema values. -- Existing tests, type checks, build, formatting, and linting pass.