From 90741cbc8fce3f6890b4e9947d395f6735116c07 Mon Sep 17 00:00:00 2001 From: Adam Jacob Date: Wed, 28 Jan 2026 13:08:54 -0800 Subject: [PATCH] feat: implement model method run command Add the `model method run ` command to execute model methods and persist resulting resources. Changes: - Add MethodExecutionService domain service for method execution with input validation against method schemas - Add model_method_run CLI command with support for lookup by ID or name - Add interactive and JSON output rendering for method execution results - Update input's resourceId after successful method execution - Register method subcommand under the model command Command hierarchy: swamp model method run Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 5 + integration/echo_model_test.ts | 224 ++++++++++++++++++ src/cli/commands/model_create.ts | 4 +- src/cli/commands/model_method_run.ts | 160 +++++++++++++ src/cli/commands/model_method_run_test.ts | 41 ++++ src/domain/models/method_execution_service.ts | 67 ++++++ .../models/method_execution_service_test.ts | 101 ++++++++ .../output/model_method_run_output.tsx | 80 +++++++ .../output/model_method_run_output_test.tsx | 145 ++++++++++++ 9 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands/model_method_run.ts create mode 100644 src/cli/commands/model_method_run_test.ts create mode 100644 src/domain/models/method_execution_service.ts create mode 100644 src/domain/models/method_execution_service_test.ts create mode 100644 src/presentation/output/model_method_run_output.tsx create mode 100644 src/presentation/output/model_method_run_output_test.tsx diff --git a/CLAUDE.md b/CLAUDE.md index f9015c00..af06e345 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,11 @@ Deno based CLI for doing AI Native Automation. +## Planning + +When planning new features, always use the `ddd` skill to inform the +architecture. + ## Code Style - TypeScript strict mode, no `any` types diff --git a/integration/echo_model_test.ts b/integration/echo_model_test.ts index c9f6c761..42a81482 100644 --- a/integration/echo_model_test.ts +++ b/integration/echo_model_test.ts @@ -282,3 +282,227 @@ Deno.test("CLI: model create command rejects unknown model type", async () => { assertStringIncludes(result.stderr, "Unknown model type"); }); }); + +// model method run integration tests + +Deno.test("CLI: model method run creates resource", async () => { + await withTempDir(async (repoDir) => { + // First create a model + const createResult = await runCliCommand( + [ + "model", + "create", + "swamp/echo", + "method-run-test", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals( + createResult.code, + 0, + `Create should succeed. stderr: ${createResult.stderr}`, + ); + const createOutput = JSON.parse(createResult.stdout); + + // Update input file to add message attribute + const inputRepo = new YamlInputRepository(repoDir); + const input = await inputRepo.findByName( + ECHO_MODEL_TYPE, + "method-run-test", + ); + assertEquals(input !== null, true, "Input should exist"); + input!.setAttribute("message", "Hello from CLI!"); + await inputRepo.save(ECHO_MODEL_TYPE, input!); + + // Run the method + const runResult = await runCliCommand( + [ + "model", + "method", + "run", + "method-run-test", + "write", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals( + runResult.code, + 0, + `Method run should succeed. stderr: ${runResult.stderr}`, + ); + + // Verify JSON output + const runOutput = JSON.parse(runResult.stdout); + assertEquals(runOutput.modelId, createOutput.id); + assertEquals(runOutput.modelName, "method-run-test"); + assertEquals(runOutput.type, "swamp/echo"); + assertEquals(runOutput.methodName, "write"); + assertEquals(typeof runOutput.resourceId, "string"); + assertStringIncludes(runOutput.resourcePath, "resources/swamp/echo"); + assertEquals(runOutput.resourceAttributes.message, "Hello from CLI!"); + assertEquals(typeof runOutput.resourceAttributes.timestamp, "string"); + + // Verify resource file was created + assertEquals( + existsSync(runOutput.resourcePath), + true, + "Resource file should exist", + ); + + // Verify input was updated with resourceId + const updatedInput = await inputRepo.findByName( + ECHO_MODEL_TYPE, + "method-run-test", + ); + assertEquals(updatedInput!.resourceId, runOutput.resourceId); + }); +}); + +Deno.test("CLI: model method run by model ID", async () => { + await withTempDir(async (repoDir) => { + // Create a model and set up its attributes + const createResult = await runCliCommand( + [ + "model", + "create", + "swamp/echo", + "run-by-id-test", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals(createResult.code, 0); + const createOutput = JSON.parse(createResult.stdout); + + // Update input with message attribute + const inputRepo = new YamlInputRepository(repoDir); + const input = await inputRepo.findByName(ECHO_MODEL_TYPE, "run-by-id-test"); + input!.setAttribute("message", "Using ID"); + await inputRepo.save(ECHO_MODEL_TYPE, input!); + + // Run method using model ID instead of name + const runResult = await runCliCommand( + [ + "model", + "method", + "run", + createOutput.id, + "write", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals(runResult.code, 0, `stderr: ${runResult.stderr}`); + + const runOutput = JSON.parse(runResult.stdout); + assertEquals(runOutput.modelId, createOutput.id); + assertEquals(runOutput.resourceAttributes.message, "Using ID"); + }); +}); + +Deno.test("CLI: model method run fails for unknown model", async () => { + await withTempDir(async (repoDir) => { + const result = await runCliCommand( + [ + "model", + "method", + "run", + "nonexistent-model", + "write", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals(result.code !== 0, true, "Should fail for unknown model"); + assertStringIncludes(result.stderr, "Model not found"); + }); +}); + +Deno.test("CLI: model method run fails for unknown method", async () => { + await withTempDir(async (repoDir) => { + // Create a model + const createResult = await runCliCommand( + [ + "model", + "create", + "swamp/echo", + "unknown-method-test", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals(createResult.code, 0); + + // Try to run a nonexistent method + const runResult = await runCliCommand( + [ + "model", + "method", + "run", + "unknown-method-test", + "nonexistent", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals(runResult.code !== 0, true, "Should fail for unknown method"); + assertStringIncludes(runResult.stderr, "Unknown method 'nonexistent'"); + assertStringIncludes(runResult.stderr, "Available methods: write"); + }); +}); + +Deno.test("CLI: model method run fails for missing required attributes", async () => { + await withTempDir(async (repoDir) => { + // Create a model without setting the required 'message' attribute + const createResult = await runCliCommand( + [ + "model", + "create", + "swamp/echo", + "missing-attrs-test", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals(createResult.code, 0); + + // Try to run the method (should fail validation) + const runResult = await runCliCommand( + [ + "model", + "method", + "run", + "missing-attrs-test", + "write", + "--repo-dir", + repoDir, + "--json", + ], + Deno.cwd(), + ); + assertEquals( + runResult.code !== 0, + true, + "Should fail for missing attributes", + ); + assertStringIncludes(runResult.stderr, "Input validation failed"); + }); +}); diff --git a/src/cli/commands/model_create.ts b/src/cli/commands/model_create.ts index 0eb8dff0..03cab14b 100644 --- a/src/cli/commands/model_create.ts +++ b/src/cli/commands/model_create.ts @@ -9,6 +9,7 @@ import { ModelInput } from "../../domain/models/model_input.ts"; import { YamlInputRepository } from "../../infrastructure/persistence/yaml_input_repository.ts"; import { modelRegistry } from "../../domain/models/model.ts"; import { modelValidateCommand } from "./model_validate.ts"; +import { modelMethodCommand } from "./model_method_run.ts"; // deno-lint-ignore no-explicit-any type AnyOptions = any; @@ -72,4 +73,5 @@ export const modelCommand = new Command() this.showHelp(); }) .command("create", modelCreateCommand) - .command("validate", modelValidateCommand); + .command("validate", modelValidateCommand) + .command("method", modelMethodCommand); diff --git a/src/cli/commands/model_method_run.ts b/src/cli/commands/model_method_run.ts new file mode 100644 index 00000000..7292ca25 --- /dev/null +++ b/src/cli/commands/model_method_run.ts @@ -0,0 +1,160 @@ +import { Command } from "@cliffy/command"; +import { + type ModelMethodRunData, + renderModelMethodRun, +} from "../../presentation/output/model_method_run_output.tsx"; +import { createContext, type GlobalOptions } from "../context.ts"; +import { + createModelInputId, + type ModelInput, +} from "../../domain/models/model_input.ts"; +import type { ModelType } from "../../domain/models/model_type.ts"; +import { YamlInputRepository } from "../../infrastructure/persistence/yaml_input_repository.ts"; +import { YamlResourceRepository } from "../../infrastructure/persistence/yaml_resource_repository.ts"; +import { modelRegistry } from "../../domain/models/model.ts"; +import { DefaultMethodExecutionService } from "../../domain/models/method_execution_service.ts"; + +/** + * UUID v4 regex pattern for detecting if an argument is a UUID. + */ +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Checks if a string looks like a UUID. + */ +function isUuid(value: string): boolean { + return UUID_PATTERN.test(value); +} + +/** + * Finds an input by ID, searching across all registered model types. + */ +async function findInputByIdGlobal( + inputRepo: YamlInputRepository, + id: string, +): Promise<{ input: ModelInput; type: ModelType } | null> { + const inputId = createModelInputId(id); + + for (const type of modelRegistry.types()) { + const input = await inputRepo.findById(type, inputId); + if (input) { + return { input, type }; + } + } + + return null; +} + +// deno-lint-ignore no-explicit-any +type AnyOptions = any; + +export const modelMethodRunCommand = new Command() + .name("run") + .description("Execute a method on a model") + .arguments(" ") + .option("--repo-dir ", "Repository directory", { default: "." }) + .action( + async function ( + options: AnyOptions, + modelIdOrName: string, + methodName: string, + ) { + const ctx = createContext(options as GlobalOptions, "model-method-run"); + const repoDir = options.repoDir ?? "."; + const inputRepo = new YamlInputRepository(repoDir); + const resourceRepo = new YamlResourceRepository(repoDir); + const executionService = new DefaultMethodExecutionService(); + + ctx.logger + .debug`Running method '${methodName}' on model: ${modelIdOrName}`; + + // Look up the model input + let input: ModelInput; + let modelType: ModelType; + + if (isUuid(modelIdOrName)) { + ctx.logger.debug`Looking up by ID: ${modelIdOrName}`; + const result = await findInputByIdGlobal(inputRepo, modelIdOrName); + if (!result) { + throw new Error(`Model not found: ${modelIdOrName}`); + } + input = result.input; + modelType = result.type; + } else { + ctx.logger.debug`Looking up by name: ${modelIdOrName}`; + const result = await inputRepo.findByNameGlobal(modelIdOrName); + if (!result) { + throw new Error(`Model not found: ${modelIdOrName}`); + } + input = result.input; + modelType = result.type; + } + + ctx.logger + .debug`Found model: id=${input.id}, type=${modelType.normalized}`; + + // Get the model definition + const definition = modelRegistry.get(modelType); + if (!definition) { + throw new Error(`Unknown model type: ${modelType.normalized}`); + } + + // Validate method exists on the model + const method = definition.methods[methodName]; + if (!method) { + const availableMethods = Object.keys(definition.methods).join(", "); + throw new Error( + `Unknown method '${methodName}' for type '${modelType.normalized}'. Available methods: ${ + availableMethods || "none" + }`, + ); + } + + ctx.logger.debug`Executing method '${methodName}'`; + + // Execute the method + const result = await executionService.execute( + input, + method, + { repoDir }, + ); + + ctx.logger + .debug`Method executed, resource created: ${result.resource.id}`; + + // Save the resource + await resourceRepo.save(modelType, result.resource); + const resourcePath = resourceRepo.getPath(modelType, result.resource.id); + + ctx.logger.debug`Resource saved to: ${resourcePath}`; + + // Update input's resourceId and save + input.setResourceId(result.resource.id); + await inputRepo.save(modelType, input); + + ctx.logger.debug`Input updated with resourceId: ${result.resource.id}`; + + // Render output + const data: ModelMethodRunData = { + modelId: input.id, + modelName: input.name, + type: modelType.normalized, + methodName, + resourceId: result.resource.id, + resourcePath, + resourceAttributes: result.resource.attributes, + }; + + renderModelMethodRun(data, ctx.outputMode); + ctx.logger.debug("Method run command completed"); + }, + ); + +export const modelMethodCommand = new Command() + .name("method") + .description("Execute model methods") + .action(function () { + this.showHelp(); + }) + .command("run", modelMethodRunCommand); diff --git a/src/cli/commands/model_method_run_test.ts b/src/cli/commands/model_method_run_test.ts new file mode 100644 index 00000000..0a0cfb5b --- /dev/null +++ b/src/cli/commands/model_method_run_test.ts @@ -0,0 +1,41 @@ +import { assertEquals } from "@std/assert"; +import { initializeLogging } from "../../infrastructure/logging/logger.ts"; + +// Initialize logging for tests +await initializeLogging({ debugLogs: false }); + +// Note: Full CLI integration tests are in integration/echo_model_test.ts +// These tests verify the command module loads correctly + +Deno.test("modelMethodRunCommand module loads", async () => { + const { modelMethodRunCommand } = await import("./model_method_run.ts"); + assertEquals(modelMethodRunCommand.getName(), "run"); +}); + +Deno.test("modelMethodRunCommand has correct description", async () => { + const { modelMethodRunCommand } = await import("./model_method_run.ts"); + assertEquals( + modelMethodRunCommand.getDescription(), + "Execute a method on a model", + ); +}); + +Deno.test("modelMethodCommand module loads", async () => { + const { modelMethodCommand } = await import("./model_method_run.ts"); + assertEquals(modelMethodCommand.getName(), "method"); +}); + +Deno.test("modelMethodCommand has correct description", async () => { + const { modelMethodCommand } = await import("./model_method_run.ts"); + assertEquals( + modelMethodCommand.getDescription(), + "Execute model methods", + ); +}); + +Deno.test("modelMethodCommand has run as subcommand", async () => { + const { modelMethodCommand } = await import("./model_method_run.ts"); + const commands = modelMethodCommand.getCommands(); + const runCommand = commands.find((cmd) => cmd.getName() === "run"); + assertEquals(runCommand !== undefined, true); +}); diff --git a/src/domain/models/method_execution_service.ts b/src/domain/models/method_execution_service.ts new file mode 100644 index 00000000..6b5dc640 --- /dev/null +++ b/src/domain/models/method_execution_service.ts @@ -0,0 +1,67 @@ +import type { z } from "zod"; +import type { MethodContext, MethodDefinition, MethodResult } from "./model.ts"; +import type { ModelInput } from "./model_input.ts"; + +/** + * Formats a Zod error into a human-readable string. + */ +function formatZodError(error: z.ZodError): string { + if (error.issues.length === 1) { + const issue = error.issues[0]; + const path = issue.path.length > 0 ? ` at "${issue.path.join(".")}"` : ""; + return `${issue.message}${path}`; + } + return error.issues + .map((issue) => { + const path = issue.path.length > 0 ? ` at "${issue.path.join(".")}"` : ""; + return `${issue.message}${path}`; + }) + .join("; "); +} + +/** + * Domain service interface for method execution. + */ +export interface MethodExecutionService { + /** + * Executes a model method with the given input and context. + * + * @param input - The model input containing attributes + * @param method - The method definition to execute + * @param context - Execution context + * @returns The method result containing the created resource + * @throws Error if input validation fails + */ + execute( + input: ModelInput, + method: MethodDefinition, + context: MethodContext, + ): Promise; +} + +/** + * Default implementation of the method execution service. + * + * Validates input attributes against the method's schema before execution. + */ +export class DefaultMethodExecutionService implements MethodExecutionService { + execute( + input: ModelInput, + method: MethodDefinition, + context: MethodContext, + ): Promise { + // Validate input attributes against method's schema + const validationResult = method.inputAttributesSchema.safeParse( + input.attributes, + ); + + if (!validationResult.success) { + throw new Error( + `Input validation failed: ${formatZodError(validationResult.error)}`, + ); + } + + // Execute the method + return method.execute(input, context); + } +} diff --git a/src/domain/models/method_execution_service_test.ts b/src/domain/models/method_execution_service_test.ts new file mode 100644 index 00000000..117d64a3 --- /dev/null +++ b/src/domain/models/method_execution_service_test.ts @@ -0,0 +1,101 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { DefaultMethodExecutionService } from "./method_execution_service.ts"; +import { ModelInput } from "./model_input.ts"; +import { echoModel } from "./echo/echo_model.ts"; + +Deno.test("execute with valid input returns method result", async () => { + const service = new DefaultMethodExecutionService(); + const input = ModelInput.create({ + name: "test-input", + attributes: { message: "Hello, world!" }, + }); + + const result = await service.execute( + input, + echoModel.methods.write, + { repoDir: "." }, + ); + + assertEquals(result.resource.inputId, input.id); + assertEquals(result.resource.attributes.message, "Hello, world!"); + assertEquals(typeof result.resource.attributes.timestamp, "string"); +}); + +Deno.test("execute with missing required attribute throws error", () => { + const service = new DefaultMethodExecutionService(); + const input = ModelInput.create({ + name: "test-input", + attributes: {}, // Missing required 'message' + }); + + assertThrows( + () => + service.execute( + input, + echoModel.methods.write, + { repoDir: "." }, + ), + Error, + "Input validation failed", + ); +}); + +Deno.test("execute with invalid attribute type throws error", () => { + const service = new DefaultMethodExecutionService(); + const input = ModelInput.create({ + name: "test-input", + attributes: { message: 123 }, // Should be string + }); + + assertThrows( + () => + service.execute( + input, + echoModel.methods.write, + { repoDir: "." }, + ), + Error, + "Input validation failed", + ); +}); + +Deno.test("execute with empty message throws error", () => { + const service = new DefaultMethodExecutionService(); + const input = ModelInput.create({ + name: "test-input", + attributes: { message: "" }, // Empty message fails min(1) validation + }); + + assertThrows( + () => + service.execute( + input, + echoModel.methods.write, + { repoDir: "." }, + ), + Error, + "Input validation failed", + ); +}); + +Deno.test("execute error message includes Zod details", () => { + const service = new DefaultMethodExecutionService(); + const input = ModelInput.create({ + name: "test-input", + attributes: { wrongField: "value" }, + }); + + try { + service.execute( + input, + echoModel.methods.write, + { repoDir: "." }, + ); + throw new Error("Expected error to be thrown"); + } catch (error) { + const message = (error as Error).message; + assertEquals(message.startsWith("Input validation failed:"), true); + // Should mention the missing 'message' field + assertEquals(message.includes("message"), true); + } +}); diff --git a/src/presentation/output/model_method_run_output.tsx b/src/presentation/output/model_method_run_output.tsx new file mode 100644 index 00000000..6aefae70 --- /dev/null +++ b/src/presentation/output/model_method_run_output.tsx @@ -0,0 +1,80 @@ +// deno-lint-ignore verbatim-module-syntax +import React from "react"; +import { Box, render, Text } from "ink"; +import type { OutputMode } from "./output.tsx"; + +export interface ModelMethodRunData { + modelId: string; + modelName: string; + type: string; + methodName: string; + resourceId: string; + resourcePath: string; + resourceAttributes: Record; +} + +export function renderModelMethodRun( + data: ModelMethodRunData, + mode: OutputMode, +): void { + if (mode === "json") { + console.log(JSON.stringify(data, null, 2)); + } else { + renderInteractiveModelMethodRun(data); + } +} + +function renderInteractiveModelMethodRun(data: ModelMethodRunData): void { + const { unmount } = render(); + unmount(); +} + +interface AttributeItemProps { + name: string; + value: unknown; +} + +function AttributeItem(props: AttributeItemProps): React.ReactElement { + const displayValue = typeof props.value === "string" + ? props.value + : JSON.stringify(props.value); + + return ( + + + {props.name}: {displayValue} + + ); +} + +export function ModelMethodRunDisplay( + props: ModelMethodRunData, +): React.ReactElement { + const checkmark = "\u2713"; + const attributeEntries = Object.entries(props.resourceAttributes); + + return ( + + + {checkmark} Method ' + {props.methodName}' executed successfully + + + + Model: {props.modelName} ( + {props.type}) + + + Resource ID: {props.resourceId} + + + Resource Path: {props.resourcePath} + + + Resource Attributes: + {attributeEntries.map(([name, value]) => ( + + ))} + + ); +} diff --git a/src/presentation/output/model_method_run_output_test.tsx b/src/presentation/output/model_method_run_output_test.tsx new file mode 100644 index 00000000..14e1cbfe --- /dev/null +++ b/src/presentation/output/model_method_run_output_test.tsx @@ -0,0 +1,145 @@ +// deno-lint-ignore verbatim-module-syntax +import React from "react"; +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { render } from "ink-testing-library"; +import { + type ModelMethodRunData, + ModelMethodRunDisplay, + renderModelMethodRun, +} from "./model_method_run_output.tsx"; + +const inkTestOptions = { sanitizeOps: false, sanitizeResources: false }; + +const testData: ModelMethodRunData = { + modelId: "550e8400-e29b-41d4-a716-446655440000", + modelName: "test-model", + type: "swamp/echo", + methodName: "write", + resourceId: "660e8400-e29b-41d4-a716-446655440000", + resourcePath: + "/path/to/resources/swamp/echo/660e8400-e29b-41d4-a716-446655440000.yaml", + resourceAttributes: { + message: "Hello, world!", + timestamp: "2026-01-28T12:00:00.000Z", + }, +}; + +Deno.test({ + name: "ModelMethodRunDisplay renders method name", + ...inkTestOptions, + fn: () => { + const { lastFrame } = render(); + const output = lastFrame() ?? ""; + + assertStringIncludes(output, "write"); + assertStringIncludes(output, "executed successfully"); + }, +}); + +Deno.test({ + name: "ModelMethodRunDisplay renders model name and type", + ...inkTestOptions, + fn: () => { + const { lastFrame } = render(); + const output = lastFrame() ?? ""; + + assertStringIncludes(output, "test-model"); + assertStringIncludes(output, "swamp/echo"); + }, +}); + +Deno.test({ + name: "ModelMethodRunDisplay renders resource ID", + ...inkTestOptions, + fn: () => { + const { lastFrame } = render(); + const output = lastFrame() ?? ""; + + assertStringIncludes(output, "660e8400-e29b-41d4-a716-446655440000"); + }, +}); + +Deno.test({ + name: "ModelMethodRunDisplay renders resource path", + ...inkTestOptions, + fn: () => { + const { lastFrame } = render(); + const output = lastFrame() ?? ""; + + assertStringIncludes(output, "/path/to/resources/swamp/echo"); + }, +}); + +Deno.test({ + name: "ModelMethodRunDisplay renders resource attributes", + ...inkTestOptions, + fn: () => { + const { lastFrame } = render(); + const output = lastFrame() ?? ""; + + assertStringIncludes(output, "message:"); + assertStringIncludes(output, "Hello, world!"); + assertStringIncludes(output, "timestamp:"); + assertStringIncludes(output, "2026-01-28"); + }, +}); + +Deno.test({ + name: "ModelMethodRunDisplay renders checkmark", + ...inkTestOptions, + fn: () => { + const { lastFrame } = render(); + const output = lastFrame() ?? ""; + + assertStringIncludes(output, "\u2713"); + }, +}); + +Deno.test("renderModelMethodRun with json mode outputs valid JSON", () => { + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + renderModelMethodRun(testData, "json"); + assertEquals(logs.length, 1); + const parsed = JSON.parse(logs[0]); + assertEquals(parsed.modelId, testData.modelId); + assertEquals(parsed.modelName, testData.modelName); + assertEquals(parsed.type, testData.type); + assertEquals(parsed.methodName, testData.methodName); + assertEquals(parsed.resourceId, testData.resourceId); + assertEquals(parsed.resourcePath, testData.resourcePath); + assertEquals(parsed.resourceAttributes.message, "Hello, world!"); + assertEquals( + parsed.resourceAttributes.timestamp, + "2026-01-28T12:00:00.000Z", + ); + } finally { + console.log = originalLog; + } +}); + +Deno.test("renderModelMethodRun JSON preserves attribute types", () => { + const dataWithNumber: ModelMethodRunData = { + ...testData, + resourceAttributes: { + count: 42, + enabled: true, + }, + }; + + const logs: string[] = []; + const originalLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + renderModelMethodRun(dataWithNumber, "json"); + assertEquals(logs.length, 1); + const parsed = JSON.parse(logs[0]); + assertEquals(parsed.resourceAttributes.count, 42); + assertEquals(parsed.resourceAttributes.enabled, true); + } finally { + console.log = originalLog; + } +});