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;
+ }
+});