From fcef3819955455d80a1aaaa5f6a9d42474577f04 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Thu, 13 Nov 2025 13:58:47 -0800 Subject: [PATCH 1/3] feat: introduce pattern() function to replace recipe() with cleaner API (#2070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add new `pattern` definition, which is a subset of `recipe` with saner argument order * feat(ts-transformers): add support for pattern() as alternative to recipe() Add comprehensive TypeScript transformer support for the new `pattern()` function, which simplifies the API by removing the optional name parameter and using a cleaner argument order: pattern(fn, argSchema?, resSchema?). Changes: - Update schema-injection.ts to handle pattern() calls with three variants: * pattern((input) => {...}) * pattern((input) => {...}) * pattern((input: Type) => {...}) - Add "pattern" to BUILDER_SYMBOL_NAMES in call-kind.ts for recognition - Add comprehensive test fixtures across all categories: * ast-transform: counter-pattern, pattern-with-type, pattern-array-map * closures: computed-pattern variants, opaque-ref-map patterns * jsx-expressions: pattern-with-cells, pattern-statements-vs-jsx * schema-transform: pattern-with-types All tests passing (16 passed, 178 steps). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * refactor(ts-transformers): use TypeScript inference for pattern schemas Improve pattern() transformation to leverage TypeScript's type checker instead of reading type arguments directly, making it more robust and capable of inferring result types automatically. Before: Only handled explicit type arguments, couldn't infer result types After: Uses collectFunctionSchemaTypeNodes to infer both argument and result types from function signatures, with type args as hints Key improvements: - Result schemas now automatically inferred from function return types - More flexible: works with partial type information - Less brittle: doesn't fail on missing type arguments - Leverages existing inference infrastructure used by other builders - Properly registers inferred types with type registry Example transformation: ```typescript // Input pattern((input: MyInput) => { return { result: input.value * 2 }; }) // Output (now includes inferred result schema) pattern((input: MyInput) => {...}, { type: "object", properties: { value: { type: "number" } } }, { type: "object", properties: { result: { type: "number" } } } ) ``` All tests passing with updated expected outputs reflecting inferred schemas. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- packages/api/index.ts | 21 ++ packages/runner/src/builder/factory.ts | 3 +- packages/runner/src/builder/recipe.ts | 26 ++ packages/runner/src/builder/types.ts | 3 + packages/ts-transformers/src/ast/call-kind.ts | 1 + .../src/transformers/schema-injection.ts | 69 +++++ .../counter-pattern.expected.tsx | 177 ++++++++++++ .../ast-transform/counter-pattern.input.tsx | 34 +++ .../pattern-array-map.expected.tsx | 189 +++++++++++++ .../ast-transform/pattern-array-map.input.tsx | 28 ++ .../pattern-with-type.expected.tsx | 30 ++ .../ast-transform/pattern-with-type.input.tsx | 12 + .../computed-pattern-param-mixed.expected.tsx | 69 +++++ .../computed-pattern-param-mixed.input.tsx | 14 + .../computed-pattern-param.expected.tsx | 49 ++++ .../closures/computed-pattern-param.input.tsx | 8 + .../computed-pattern-typed.expected.tsx | 41 +++ .../closures/computed-pattern-typed.input.tsx | 8 + ...ttern-computed-opaque-ref-map.expected.tsx | 42 +++ .../pattern-computed-opaque-ref-map.input.tsx | 9 + ...pattern-derive-opaque-ref-map.expected.tsx | 42 +++ .../pattern-derive-opaque-ref-map.input.tsx | 9 + .../pattern-statements-vs-jsx.expected.tsx | 222 +++++++++++++++ .../pattern-statements-vs-jsx.input.tsx | 61 ++++ .../pattern-with-cells.expected.tsx | 139 ++++++++++ .../pattern-with-cells.input.tsx | 15 + .../pattern-with-types.expected.tsx | 261 ++++++++++++++++++ .../pattern-with-types.input.tsx | 72 +++++ 28 files changed, 1653 insertions(+), 1 deletion(-) create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/computed-pattern-param.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.input.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.expected.tsx create mode 100644 packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.input.tsx diff --git a/packages/api/index.ts b/packages/api/index.ts index f6308e9ac5..71da7c3543 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -966,6 +966,25 @@ export interface BuiltInCompileAndRunState { } // Function type definitions +export type PatternFunction = { + ( + fn: (input: OpaqueRef>) => Opaque, + ): RecipeFactory; + + ( + fn: (input: OpaqueRef>) => unknown, + ): RecipeFactory>; + + ( + fn: ( + input: OpaqueRef>>, + ) => Opaque>, + argumentSchema: IS, + resultSchema: OS, + ): RecipeFactory, Schema>; +}; + +/** @deprecated Use pattern() instead */ export type RecipeFunction = { // Function-only overload ( @@ -1253,6 +1272,8 @@ export type GetRecipeEnvironmentFunction = () => RecipeEnvironment; // Re-export all function types as values for destructuring imports // These will be implemented by the factory +export declare const pattern: PatternFunction; +/** @deprecated Use pattern() instead */ export declare const recipe: RecipeFunction; export declare const patternTool: PatternToolFunction; export declare const lift: LiftFunction; diff --git a/packages/runner/src/builder/factory.ts b/packages/runner/src/builder/factory.ts index 494bfbccb2..09271e3504 100644 --- a/packages/runner/src/builder/factory.ts +++ b/packages/runner/src/builder/factory.ts @@ -22,7 +22,7 @@ import { UI, } from "./types.ts"; import { h } from "@commontools/html"; -import { recipe } from "./recipe.ts"; +import { pattern, recipe } from "./recipe.ts"; import { byRef, computed, derive, handler, lift } from "./module.ts"; import { compileAndRun, @@ -98,6 +98,7 @@ export const createBuilder = (): { compileAndRun, navigateTo, wish, + pattern, // Cell creation cell: cellConstructorFactory("cell").of, diff --git a/packages/runner/src/builder/recipe.ts b/packages/runner/src/builder/recipe.ts index 5de2b199a4..883282c6c5 100644 --- a/packages/runner/src/builder/recipe.ts +++ b/packages/runner/src/builder/recipe.ts @@ -9,6 +9,7 @@ import { type Opaque, type OpaqueCell, type OpaqueRef, + type PatternFunction, type Recipe, type RecipeFactory, type SchemaWithoutCell, @@ -41,6 +42,31 @@ import { MemorySpace, } from "../storage/interface.ts"; +export const pattern: PatternFunction = ( + fn: (input: any) => any, + argumentSchema?: JSONSchema, + resultSchema?: JSONSchema, +) => { + const frame = pushFrame(); + + const inputs = opaqueRef(undefined, argumentSchema); + + const outputs = fn!(inputs); + + applyInputIfcToOutput(inputs, outputs); + + const result = factoryFromRecipe( + argumentSchema, + resultSchema, + inputs, + outputs, + ); + + popFrame(frame); + + return result; +}; + /** Declare a recipe * * @param fn A function that creates the recipe graph diff --git a/packages/runner/src/builder/types.ts b/packages/runner/src/builder/types.ts index fd8e5b222a..1e4279392b 100644 --- a/packages/runner/src/builder/types.ts +++ b/packages/runner/src/builder/types.ts @@ -34,6 +34,7 @@ import type { NavigateToFunction, Opaque, OpaqueRef, + PatternFunction, PatternToolFunction, Recipe, RecipeFunction, @@ -102,6 +103,7 @@ export type { Opaque, OpaqueCell, OpaqueRef, + PatternFunction, Props, Recipe, RecipeFactory, @@ -238,6 +240,7 @@ export type Frame = { // Builder functions interface export interface BuilderFunctionsAndConstants { // Recipe creation + pattern: PatternFunction; recipe: RecipeFunction; patternTool: PatternToolFunction; diff --git a/packages/ts-transformers/src/ast/call-kind.ts b/packages/ts-transformers/src/ast/call-kind.ts index 08d2665d04..af64abdd9f 100644 --- a/packages/ts-transformers/src/ast/call-kind.ts +++ b/packages/ts-transformers/src/ast/call-kind.ts @@ -4,6 +4,7 @@ import { isCommonToolsSymbol } from "../core/mod.ts"; const BUILDER_SYMBOL_NAMES = new Set([ "recipe", + "pattern", "handler", "lift", "computed", diff --git a/packages/ts-transformers/src/transformers/schema-injection.ts b/packages/ts-transformers/src/transformers/schema-injection.ts index c6546fcf0f..87d01e2940 100644 --- a/packages/ts-transformers/src/transformers/schema-injection.ts +++ b/packages/ts-transformers/src/transformers/schema-injection.ts @@ -243,6 +243,75 @@ export class SchemaInjectionTransformer extends Transformer { } } + if (callKind?.kind === "builder" && callKind.builderName === "pattern") { + const factory = transformation.factory; + const typeArgs = node.typeArguments; + const argsArray = Array.from(node.arguments); + + // Get the function argument (should be the first and only argument) + const patternFunction = argsArray[0]; + if ( + !patternFunction || + !(ts.isFunctionExpression(patternFunction) || + ts.isArrowFunction(patternFunction)) + ) { + return ts.visitEachChild(node, visit, transformation); + } + + // Use type arguments as a hint for inference + const typeArgHints: ts.Type[] = []; + if (typeArgs) { + for (const typeArg of typeArgs) { + const type = checker.getTypeFromTypeNode(typeArg); + typeArgHints.push(type); + } + } + + // Collect inferred types from the function + const inferred = collectFunctionSchemaTypeNodes( + patternFunction, + checker, + sourceFile, + typeArgHints[0], // Pass first type arg as fallback for parameter inference + ); + + // Infer types from the function signature and type arguments + const argumentTypeNode = inferred.argument; + const argumentType = inferred.argumentType; + const resultTypeNode = inferred.result; + const resultType = inferred.resultType; + + // Build the new arguments array: [fn, argSchema?, resSchema?] + const newArgs: ts.Expression[] = [patternFunction]; + + if (argumentTypeNode) { + const argSchemaCall = createToSchemaCall(context, argumentTypeNode); + if (argumentType && typeRegistry) { + typeRegistry.set(argSchemaCall, argumentType); + } + newArgs.push(argSchemaCall); + } + + if (resultTypeNode) { + const resSchemaCall = createToSchemaCall(context, resultTypeNode); + if (resultType && typeRegistry) { + typeRegistry.set(resSchemaCall, resultType); + } + newArgs.push(resSchemaCall); + } + + // Only transform if we have at least one schema + if (newArgs.length > 1) { + const updated = factory.createCallExpression( + node.expression, + undefined, + newArgs, + ); + + return ts.visitEachChild(updated, visit, transformation); + } + } + if ( callKind?.kind === "builder" && callKind.builderName === "handler" ) { diff --git a/packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.expected.tsx new file mode 100644 index 0000000000..5732638e1d --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.expected.tsx @@ -0,0 +1,177 @@ +import * as __ctHelpers from "commontools"; +import { Cell, Default, handler, NAME, pattern, str, UI } from "commontools"; +interface CounterState { + value: Cell; +} +interface PatternState { + value: Default; +} +const increment = handler(true as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + value: { + type: "number", + asCell: true + } + }, + required: ["value"] +} as const satisfies __ctHelpers.JSONSchema, (_e, state) => { + state.value.set(state.value.get() + 1); +}); +const decrement = handler(true as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + value: { + type: "number", + asCell: true + } + }, + required: ["value"] +} as const satisfies __ctHelpers.JSONSchema, (_, state: { + value: Cell; +}) => { + state.value.set(state.value.get() - 1); +}); +export default pattern((state) => { + return { + [NAME]: str `Simple counter: ${state.value}`, + [UI]: (
+ - +
    +
  • next number: {__ctHelpers.ifElse(state.value, __ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value + 1), "unknown")}
  • +
+ + +
), + value: state.value, + }; +}, { + type: "object", + properties: { + value: { + type: "number", + default: 0 + } + }, + required: ["value"], + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + $NAME: { + type: "string", + asOpaque: true + }, + $UI: { + $ref: "#/$defs/Element" + }, + value: { + type: "number", + asOpaque: true + } + }, + required: ["$NAME", "$UI", "value"], + $defs: { + Element: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + VNode: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + RenderNode: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + $ref: "#/$defs/VNode" + }, { + type: "object", + properties: {} + }, { + type: "array", + items: { + $ref: "#/$defs/RenderNode" + } + }] + }, + Props: { + type: "object", + properties: {}, + additionalProperties: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + type: "object", + additionalProperties: true + }, { + type: "array", + items: true + }, { + asCell: true + }, { + asStream: true + }, { + type: "null" + }] + } + } + } +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.input.tsx new file mode 100644 index 0000000000..12000f7839 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/counter-pattern.input.tsx @@ -0,0 +1,34 @@ +/// +import { Cell, Default, handler, NAME, pattern, str, UI } from "commontools"; + +interface CounterState { + value: Cell; +} + +interface PatternState { + value: Default; +} + +const increment = handler((_e, state) => { + state.value.set(state.value.get() + 1); +}); + +const decrement = handler((_, state: { value: Cell }) => { + state.value.set(state.value.get() - 1); +}); + +export default pattern((state) => { + return { + [NAME]: str`Simple counter: ${state.value}`, + [UI]: ( +
+ - +
    +
  • next number: {state.value ? state.value + 1 : "unknown"}
  • +
+ + +
+ ), + value: state.value, + }; +}); diff --git a/packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.expected.tsx new file mode 100644 index 0000000000..d4d8621165 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.expected.tsx @@ -0,0 +1,189 @@ +import * as __ctHelpers from "commontools"; +import { Cell, derive, handler, NAME, pattern, str, UI } from "commontools"; +const adder = handler(true as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + values: { + type: "array", + items: { + type: "string" + }, + asCell: true + } + }, + required: ["values"] +} as const satisfies __ctHelpers.JSONSchema, (_, state: { + values: Cell; +}) => { + state.values.push(Math.random().toString(36).substring(2, 15)); +}); +export default pattern(({ values }) => { + derive({ + type: "array", + items: { + type: "string" + } + } as const satisfies __ctHelpers.JSONSchema, true as const satisfies __ctHelpers.JSONSchema, values, (values) => { + console.log("values#", values?.length); + }); + return { + [NAME]: str `Simple Value: ${values.length || 0}`, + [UI]: (
+ +
+ {values.mapWithPattern(__ctHelpers.recipe({ + type: "object", + properties: { + element: { + type: "string" + }, + index: { + type: "number" + }, + params: { + type: "object", + properties: {} + } + }, + required: ["element", "params"] + } as const satisfies __ctHelpers.JSONSchema, ({ element: value, index: index, params: {} }) => (
+ {index}: {value} +
)), {})} +
+
), + values, + }; +}, { + type: "object", + properties: { + values: { + type: "array", + items: { + type: "string" + } + } + }, + required: ["values"], + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + $NAME: { + type: "string", + asOpaque: true + }, + $UI: { + $ref: "#/$defs/Element" + }, + values: { + type: "array", + items: { + type: "string" + }, + asOpaque: true + } + }, + required: ["$NAME", "$UI", "values"], + $defs: { + Element: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + VNode: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + RenderNode: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + $ref: "#/$defs/VNode" + }, { + type: "object", + properties: {} + }, { + type: "array", + items: { + $ref: "#/$defs/RenderNode" + } + }] + }, + Props: { + type: "object", + properties: {}, + additionalProperties: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + type: "object", + additionalProperties: true + }, { + type: "array", + items: true + }, { + asCell: true + }, { + asStream: true + }, { + type: "null" + }] + } + } + } +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.input.tsx new file mode 100644 index 0000000000..66285d03a5 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/pattern-array-map.input.tsx @@ -0,0 +1,28 @@ +/// +import { Cell, derive, handler, NAME, pattern, str, UI } from "commontools"; + +const adder = handler((_, state: { values: Cell }) => { + state.values.push(Math.random().toString(36).substring(2, 15)); +}); + +export default pattern<{ values: string[] }>(({ values }) => { + derive(values, (values) => { + console.log("values#", values?.length); + }); + return { + [NAME]: str`Simple Value: ${values.length || 0}`, + [UI]: ( +
+ +
+ {values.map((value, index) => ( +
+ {index}: {value} +
+ ))} +
+
+ ), + values, + }; +}); diff --git a/packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.expected.tsx new file mode 100644 index 0000000000..bf2d5431fc --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.expected.tsx @@ -0,0 +1,30 @@ +import * as __ctHelpers from "commontools"; +import { pattern } from "commontools"; +interface MyInput { + value: number; +} +export default pattern((input: MyInput) => { + return { + result: input.value * 2, + }; +}, { + type: "object", + properties: { + value: { + type: "number" + } + }, + required: ["value"] +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + result: { + type: "number" + } + }, + required: ["result"] +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.input.tsx b/packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.input.tsx new file mode 100644 index 0000000000..c5993896ca --- /dev/null +++ b/packages/ts-transformers/test/fixtures/ast-transform/pattern-with-type.input.tsx @@ -0,0 +1,12 @@ +/// +import { pattern } from "commontools"; + +interface MyInput { + value: number; +} + +export default pattern((input: MyInput) => { + return { + result: input.value * 2, + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx new file mode 100644 index 0000000000..6acbfbb009 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.expected.tsx @@ -0,0 +1,69 @@ +import * as __ctHelpers from "commontools"; +import { cell, computed, pattern } from "commontools"; +export default pattern((config: { + base: number; + multiplier: number; +}) => { + const value = cell(10); + const offset = 5; // non-cell local + const threshold = cell(15); // cell local + const result = __ctHelpers.derive({ + type: "object", + properties: { + value: { + type: "number", + asCell: true + }, + config: { + type: "object", + properties: { + base: { + type: "number" + }, + multiplier: { + type: "number" + } + }, + required: ["base", "multiplier"] + }, + offset: { + type: "number", + enum: [5] + }, + threshold: { + type: "number", + asCell: true + } + }, + required: ["value", "config", "offset", "threshold"] + } as const satisfies __ctHelpers.JSONSchema, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema, { + value: value, + config: { + base: config.base, + multiplier: config.multiplier + }, + offset: offset, + threshold: threshold + }, ({ value, config, offset, threshold }) => (value.get() + config.base + offset) * config.multiplier + threshold.get()); + return result; +}, { + type: "object", + properties: { + base: { + type: "number" + }, + multiplier: { + type: "number" + } + }, + required: ["base", "multiplier"] +} as const satisfies __ctHelpers.JSONSchema, { + type: "number", + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.input.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.input.tsx new file mode 100644 index 0000000000..47dea9ec3e --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param-mixed.input.tsx @@ -0,0 +1,14 @@ +/// +import { cell, computed, pattern } from "commontools"; + +export default pattern((config: { base: number; multiplier: number }) => { + const value = cell(10); + const offset = 5; // non-cell local + const threshold = cell(15); // cell local + + const result = computed(() => + (value.get() + config.base + offset) * config.multiplier + threshold.get() + ); + + return result; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx new file mode 100644 index 0000000000..1c6ed7a755 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.expected.tsx @@ -0,0 +1,49 @@ +import * as __ctHelpers from "commontools"; +import { cell, computed, pattern } from "commontools"; +export default pattern((config: { + multiplier: number; +}) => { + const value = cell(10); + const result = __ctHelpers.derive({ + type: "object", + properties: { + value: { + type: "number", + asCell: true + }, + config: { + type: "object", + properties: { + multiplier: { + type: "number" + } + }, + required: ["multiplier"] + } + }, + required: ["value", "config"] + } as const satisfies __ctHelpers.JSONSchema, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema, { + value: value, + config: { + multiplier: config.multiplier + } + }, ({ value, config }) => value.get() * config.multiplier); + return result; +}, { + type: "object", + properties: { + multiplier: { + type: "number" + } + }, + required: ["multiplier"] +} as const satisfies __ctHelpers.JSONSchema, { + type: "number", + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.input.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.input.tsx new file mode 100644 index 0000000000..8f3f9efbcf --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-param.input.tsx @@ -0,0 +1,8 @@ +/// +import { cell, computed, pattern } from "commontools"; + +export default pattern((config: { multiplier: number }) => { + const value = cell(10); + const result = computed(() => value.get() * config.multiplier); + return result; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx new file mode 100644 index 0000000000..d759fa4228 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.expected.tsx @@ -0,0 +1,41 @@ +import * as __ctHelpers from "commontools"; +import { cell, computed, pattern } from "commontools"; +export default pattern(({ multiplier }) => { + const value = cell(10); + const result = __ctHelpers.derive({ + type: "object", + properties: { + value: { + type: "number", + asCell: true + }, + multiplier: { + type: "number", + asOpaque: true + } + }, + required: ["value", "multiplier"] + } as const satisfies __ctHelpers.JSONSchema, { + type: "number" + } as const satisfies __ctHelpers.JSONSchema, { + value: value, + multiplier: multiplier + }, ({ value, multiplier }) => value.get() * multiplier); + return result; +}, { + type: "object", + properties: { + multiplier: { + type: "number" + } + }, + required: ["multiplier"], + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "number", + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.input.tsx b/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.input.tsx new file mode 100644 index 0000000000..2807c8d1dc --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/computed-pattern-typed.input.tsx @@ -0,0 +1,8 @@ +/// +import { cell, computed, pattern } from "commontools"; + +export default pattern<{ multiplier: number }, number>(({ multiplier }) => { + const value = cell(10); + const result = computed(() => value.get() * multiplier); + return result; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.expected.tsx b/packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.expected.tsx new file mode 100644 index 0000000000..f0fc0901a9 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.expected.tsx @@ -0,0 +1,42 @@ +import * as __ctHelpers from "commontools"; +import { computed, pattern } from "commontools"; +export default pattern((items) => { + // items is OpaqueRef as a pattern parameter + // Inside the computed callback (which becomes derive), items.map should NOT be transformed + const doubled = __ctHelpers.derive({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "number" + }, + asOpaque: true + } + }, + required: ["items"] + } as const satisfies __ctHelpers.JSONSchema, { + type: "array", + items: { + type: "number" + }, + asOpaque: true + } as const satisfies __ctHelpers.JSONSchema, { items: items }, ({ items }) => items.map((n) => n * 2)); + return doubled; +}, { + type: "array", + items: { + type: "number" + }, + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "array", + items: { + type: "number" + }, + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.input.tsx b/packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.input.tsx new file mode 100644 index 0000000000..e63990ee82 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/pattern-computed-opaque-ref-map.input.tsx @@ -0,0 +1,9 @@ +/// +import { computed, pattern } from "commontools"; + +export default pattern((items) => { + // items is OpaqueRef as a pattern parameter + // Inside the computed callback (which becomes derive), items.map should NOT be transformed + const doubled = computed(() => items.map((n) => n * 2)); + return doubled; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.expected.tsx b/packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.expected.tsx new file mode 100644 index 0000000000..333bc85022 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.expected.tsx @@ -0,0 +1,42 @@ +import * as __ctHelpers from "commontools"; +import { derive, pattern } from "commontools"; +export default pattern((items) => { + // items is OpaqueRef as a pattern parameter + // Inside the derive callback, items.map should NOT be transformed + const doubled = __ctHelpers.derive({ + type: "object", + properties: { + items: { + type: "array", + items: { + type: "number" + }, + asOpaque: true + } + }, + required: ["items"] + } as const satisfies __ctHelpers.JSONSchema, { + type: "array", + items: { + type: "number" + }, + asOpaque: true + } as const satisfies __ctHelpers.JSONSchema, { items: items }, ({ items }) => items.map((n) => n * 2)); + return doubled; +}, { + type: "array", + items: { + type: "number" + }, + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "array", + items: { + type: "number" + }, + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.input.tsx b/packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.input.tsx new file mode 100644 index 0000000000..c05bd0a316 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/pattern-derive-opaque-ref-map.input.tsx @@ -0,0 +1,9 @@ +/// +import { derive, pattern } from "commontools"; + +export default pattern((items) => { + // items is OpaqueRef as a pattern parameter + // Inside the derive callback, items.map should NOT be transformed + const doubled = derive({}, () => items.map((n) => n * 2)); + return doubled; +}); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.expected.tsx new file mode 100644 index 0000000000..12fcf6de18 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.expected.tsx @@ -0,0 +1,222 @@ +import * as __ctHelpers from "commontools"; +import { Cell, handler, NAME, pattern, str, UI } from "commontools"; +interface PatternState { + value: number; +} +const increment = handler(true as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + value: { + type: "number", + asCell: true + } + }, + required: ["value"] +} as const satisfies __ctHelpers.JSONSchema, (_e, state: { + value: Cell; +}) => { + state.value.set(state.value.get() + 1); +}); +const decrement = handler(true as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + value: { + type: "number", + asCell: true + } + }, + required: ["value"] +} as const satisfies __ctHelpers.JSONSchema, (_e, state: { + value: Cell; +}) => { + state.value.set(state.value.get() - 1); +}); +export default pattern((state) => { + // These should NOT be transformed (statement context) + const next = state.value + 1; + const previous = state.value - 1; + const doubled = state.value * 2; + const _isHigh = state.value > 10; + // This should NOT be transformed (statement context) + if (state.value > 100) { + console.log("Too high!"); + } + return { + // This template literal SHOULD be transformed (builder function context) + [NAME]: str `Simple counter: ${state.value}`, + [UI]: (
+ - +

+ {/* These SHOULD be transformed (JSX expression context) */} + Current: {state.value} +
+ Next number: {__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value + 1)} +
+ Previous: {__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value - 1)} +
+ Doubled: {__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value * 2)} +
+ Status: {__ctHelpers.ifElse(__ctHelpers.derive({ state: { + value: state.value + } }, ({ state }) => state.value > 10), "High", "Low")} +

+ + +
), + // Direct property access - no transformation needed + value: state.value, + // These should NOT be transformed (object literal in statement context) + metadata: { + next: next, + previous: previous, + doubled: doubled, + }, + }; +}, { + type: "object", + properties: { + value: { + type: "number" + } + }, + required: ["value"], + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + $NAME: { + type: "string", + asOpaque: true + }, + $UI: { + $ref: "#/$defs/Element" + }, + value: { + type: "number", + asOpaque: true + }, + metadata: { + type: "object", + properties: { + next: { + type: "number" + }, + previous: { + type: "number" + }, + doubled: { + type: "number" + } + }, + required: ["next", "previous", "doubled"] + } + }, + required: ["$NAME", "$UI", "value", "metadata"], + $defs: { + Element: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + VNode: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + RenderNode: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + $ref: "#/$defs/VNode" + }, { + type: "object", + properties: {} + }, { + type: "array", + items: { + $ref: "#/$defs/RenderNode" + } + }] + }, + Props: { + type: "object", + properties: {}, + additionalProperties: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + type: "object", + additionalProperties: true + }, { + type: "array", + items: true + }, { + asCell: true + }, { + asStream: true + }, { + type: "null" + }] + } + } + } +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.input.tsx new file mode 100644 index 0000000000..c95ba1d202 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-statements-vs-jsx.input.tsx @@ -0,0 +1,61 @@ +/// +import { Cell, handler, NAME, pattern, str, UI } from "commontools"; + +interface PatternState { + value: number; +} + +const increment = handler((_e, state: { value: Cell }) => { + state.value.set(state.value.get() + 1); +}); + +const decrement = handler((_e, state: { value: Cell }) => { + state.value.set(state.value.get() - 1); +}); + +export default pattern((state) => { + // These should NOT be transformed (statement context) + const next = state.value + 1; + const previous = state.value - 1; + const doubled = state.value * 2; + const _isHigh = state.value > 10; + + // This should NOT be transformed (statement context) + if (state.value > 100) { + console.log("Too high!"); + } + + return { + // This template literal SHOULD be transformed (builder function context) + [NAME]: str`Simple counter: ${state.value}`, + + [UI]: ( +
+ - +

+ {/* These SHOULD be transformed (JSX expression context) */} + Current: {state.value} +
+ Next number: {state.value + 1} +
+ Previous: {state.value - 1} +
+ Doubled: {state.value * 2} +
+ Status: {state.value > 10 ? "High" : "Low"} +

+ + +
+ ), + + // Direct property access - no transformation needed + value: state.value, + + // These should NOT be transformed (object literal in statement context) + metadata: { + next: next, + previous: previous, + doubled: doubled, + }, + }; +}); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.expected.tsx new file mode 100644 index 0000000000..91c1f6d906 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.expected.tsx @@ -0,0 +1,139 @@ +import * as __ctHelpers from "commontools"; +import { pattern, UI } from "commontools"; +export default pattern((cell) => { + return { + [UI]: (
+

Current value: {cell.value}

+

Next value: {__ctHelpers.derive({ cell: { + value: cell.value + } }, ({ cell }) => cell.value + 1)}

+

Double: {__ctHelpers.derive({ cell: { + value: cell.value + } }, ({ cell }) => cell.value * 2)}

+
), + value: cell.value, + }; +}, { + type: "object", + properties: { + value: { + type: "number" + } + }, + required: ["value"], + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + $UI: { + $ref: "#/$defs/Element" + }, + value: { + type: "number", + asOpaque: true + } + }, + required: ["$UI", "value"], + $defs: { + Element: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + VNode: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + RenderNode: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + $ref: "#/$defs/VNode" + }, { + type: "object", + properties: {} + }, { + type: "array", + items: { + $ref: "#/$defs/RenderNode" + } + }] + }, + Props: { + type: "object", + properties: {}, + additionalProperties: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + type: "object", + additionalProperties: true + }, { + type: "array", + items: true + }, { + asCell: true + }, { + asStream: true + }, { + type: "null" + }] + } + } + } +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.input.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.input.tsx new file mode 100644 index 0000000000..535b4976d5 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/pattern-with-cells.input.tsx @@ -0,0 +1,15 @@ +/// +import { pattern, UI } from "commontools"; + +export default pattern<{ value: number }>((cell) => { + return { + [UI]: ( +
+

Current value: {cell.value}

+

Next value: {cell.value + 1}

+

Double: {cell.value * 2}

+
+ ), + value: cell.value, + }; +}); diff --git a/packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.expected.tsx b/packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.expected.tsx new file mode 100644 index 0000000000..5cf84bcb42 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.expected.tsx @@ -0,0 +1,261 @@ +import * as __ctHelpers from "commontools"; +import { Cell, Default, handler, NAME, pattern, toSchema, UI, } from "commontools"; +interface Item { + text: Default; +} +interface InputSchemaInterface { + title: Default; + items: Default; +} +interface OutputSchemaInterface extends InputSchemaInterface { + items_count: number; +} +type InputEventType = { + detail: { + message: string; + }; +}; +const inputSchema = { + type: "object", + properties: { + title: { + type: "string", + default: "untitled" + }, + items: { + type: "array", + items: { + $ref: "#/$defs/Item" + }, + default: [] + } + }, + required: ["title", "items"], + $defs: { + Item: { + type: "object", + properties: { + text: { + type: "string", + default: "" + } + }, + required: ["text"] + } + } +} as const satisfies __ctHelpers.JSONSchema; +const outputSchema = { + type: "object", + properties: { + items_count: { + type: "number" + }, + title: { + type: "string", + default: "untitled" + }, + items: { + type: "array", + items: { + $ref: "#/$defs/Item" + }, + default: [] + } + }, + required: ["items_count", "title", "items"], + $defs: { + Item: { + type: "object", + properties: { + text: { + type: "string", + default: "" + } + }, + required: ["text"] + } + } +} as const satisfies __ctHelpers.JSONSchema; +// Handler that logs the message event +const addItem = handler // < +({ + type: "object", + properties: { + detail: { + type: "object", + properties: { + message: { + type: "string" + } + }, + required: ["message"] + } + }, + required: ["detail"] +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/$defs/Item" + }, + asCell: true + } + }, + required: ["items"], + $defs: { + Item: { + type: "object", + properties: { + text: { + type: "string", + default: "" + } + }, + required: ["text"] + } + } +} as const satisfies __ctHelpers.JSONSchema, (event: InputEventType, { items }: { + items: Cell; +}) => { + items.push({ text: event.detail.message }); +}); +export default pattern(({ title, items }) => { + const items_count = items.length; + return { + [NAME]: title, + [UI]: (
+

{title}

+

Basic pattern

+

Items count: {items_count}

+
    + {items.map((item: Item, index: number) => (
  • {item.text}
  • ))} +
+ +
), + title, + items, + items_count, + }; +}, { + type: "object", + properties: {}, + additionalProperties: true, + asOpaque: true +} as const satisfies __ctHelpers.JSONSchema, { + type: "object", + properties: { + $NAME: true, + $UI: { + $ref: "#/$defs/Element" + }, + title: true, + items: true, + items_count: true + }, + required: ["$NAME", "$UI", "title", "items", "items_count"], + $defs: { + Element: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + VNode: { + type: "object", + properties: { + type: { + type: "string", + enum: ["vnode"] + }, + name: { + type: "string" + }, + props: { + $ref: "#/$defs/Props" + }, + children: { + $ref: "#/$defs/RenderNode" + }, + $UI: { + $ref: "#/$defs/VNode" + } + }, + required: ["type", "name", "props"] + }, + RenderNode: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + $ref: "#/$defs/VNode" + }, { + type: "object", + properties: {} + }, { + type: "array", + items: { + $ref: "#/$defs/RenderNode" + } + }] + }, + Props: { + type: "object", + properties: {}, + additionalProperties: { + anyOf: [{ + type: "string" + }, { + type: "number" + }, { + type: "boolean", + enum: [false] + }, { + type: "boolean", + enum: [true] + }, { + type: "object", + additionalProperties: true + }, { + type: "array", + items: true + }, { + asCell: true + }, { + asStream: true + }, { + type: "null" + }] + } + } + } +} as const satisfies __ctHelpers.JSONSchema); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.input.tsx b/packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.input.tsx new file mode 100644 index 0000000000..991ee8b868 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/schema-transform/pattern-with-types.input.tsx @@ -0,0 +1,72 @@ +/// +import { + Cell, + Default, + handler, + NAME, + pattern, + toSchema, + UI, +} from "commontools"; + +interface Item { + text: Default; +} + +interface InputSchemaInterface { + title: Default; + items: Default; +} + +interface OutputSchemaInterface extends InputSchemaInterface { + items_count: number; +} + +type InputEventType = { + detail: { + message: string; + }; +}; + +const inputSchema = toSchema(); +const outputSchema = toSchema(); + +// Handler that logs the message event +const addItem = handler // < +// { detail: { message: string } }, +// { items: Item[] } +// > +( + (event: InputEventType, { items }: { items: Cell }) => { + items.push({ text: event.detail.message }); + }, +); + +export default pattern(({ title, items }) => { + const items_count = items.length; + + return { + [NAME]: title, + [UI]: ( +
+

{title}

+

Basic pattern

+

Items count: {items_count}

+
    + {items.map((item: Item, index: number) => ( +
  • {item.text}
  • + ))} +
+ +
+ ), + title, + items, + items_count, + }; +}, inputSchema, outputSchema); From 6c198d5b9290041722ceb3e39d5ae66f53233190 Mon Sep 17 00:00:00 2001 From: Bernhard Seefeld Date: Thu, 13 Nov 2025 14:44:37 -0800 Subject: [PATCH 2/3] fix(built-in): always return wished cell, even if value isn't defined yet (#2073) fix(built-in): always return wished cell, even if value isn't defined yet --- packages/api/index.ts | 10 ++---- packages/patterns/chatbot-list-view.tsx | 6 +++- packages/patterns/chatbot-note-composed.tsx | 5 ++- packages/patterns/note.tsx | 5 ++- packages/runner/src/builtins/wish.ts | 6 +--- packages/runner/test/wish.test.ts | 36 --------------------- 6 files changed, 13 insertions(+), 55 deletions(-) diff --git a/packages/api/index.ts b/packages/api/index.ts index 71da7c3543..ae7c62ab6e 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -1228,13 +1228,9 @@ export type CompileAndRunFunction = ( ) => OpaqueRef>; export type NavigateToFunction = (cell: OpaqueRef) => OpaqueRef; -export type WishFunction = { - (target: Opaque): OpaqueRef; - ( - target: Opaque, - defaultValue: Opaque, - ): OpaqueRef; -}; +export type WishFunction = ( + target: Opaque, +) => OpaqueRef; export type CreateNodeFactoryFunction = ( moduleSpec: Module, diff --git a/packages/patterns/chatbot-list-view.tsx b/packages/patterns/chatbot-list-view.tsx index dd5a819c22..05beea12bc 100644 --- a/packages/patterns/chatbot-list-view.tsx +++ b/packages/patterns/chatbot-list-view.tsx @@ -2,6 +2,7 @@ import { Cell, Default, + derive, handler, ID, ifElse, @@ -236,7 +237,10 @@ const extractLocalMentionable = lift< export default recipe( "Launcher", ({ selectedCharm, charmsList, theme }) => { - const allCharms = wish("#allCharms", []); + const allCharms = derive( + wish("#allCharms"), + (c) => c ?? [], + ); logCharmsList({ charmsList: charmsList as unknown as Cell }); populateChatList({ diff --git a/packages/patterns/chatbot-note-composed.tsx b/packages/patterns/chatbot-note-composed.tsx index f3bfcc2f51..79745f3b16 100644 --- a/packages/patterns/chatbot-note-composed.tsx +++ b/packages/patterns/chatbot-note-composed.tsx @@ -8,7 +8,6 @@ import { handler, NAME, navigateTo, - Opaque, OpaqueRef, recipe, wish, @@ -27,8 +26,8 @@ import { import { type MentionableCharm } from "./backlinks-index.tsx"; -function schemaifyWish(path: string, def: Opaque) { - return derive(wish(path, def), (i) => i); +function schemaifyWish(path: string, def: T) { + return derive(wish(path) as T, (i) => i ?? def); } type ChatbotNoteInput = { diff --git a/packages/patterns/note.tsx b/packages/patterns/note.tsx index 41492f99c2..657969e918 100644 --- a/packages/patterns/note.tsx +++ b/packages/patterns/note.tsx @@ -8,7 +8,6 @@ import { handler, NAME, navigateTo, - type Opaque, type OpaqueRef, patternTool, recipe, @@ -100,8 +99,8 @@ const handleCharmLinkClicked = handler }>( }, ); -function schemaifyWish(path: string, def: T | Opaque) { - return derive(wish(path, def as Opaque), (i) => i); +function schemaifyWish(path: string, def: T) { + return derive(wish(path) as T, (i) => i ?? def); } const Note = recipe( diff --git a/packages/runner/src/builtins/wish.ts b/packages/runner/src/builtins/wish.ts index 4c21e34069..89b7356a56 100644 --- a/packages/runner/src/builtins/wish.ts +++ b/packages/runner/src/builtins/wish.ts @@ -201,10 +201,6 @@ export function wish( : parsed.path; const resolvedCell = resolvePath(baseResolution.cell, combinedPath); - if (resolvedCell.get() !== undefined) { - sendResult(tx, resolvedCell); - } else { - sendResult(tx, hasDefault ? defaultCell : undefined); - } + sendResult(tx, resolvedCell); }; } diff --git a/packages/runner/test/wish.test.ts b/packages/runner/test/wish.test.ts index 991d87be6f..a8e97fd1e9 100644 --- a/packages/runner/test/wish.test.ts +++ b/packages/runner/test/wish.test.ts @@ -402,40 +402,4 @@ describe("wish built-in", () => { console.error = originalError; } }); - - it("uses provided default when target is missing", async () => { - const fallback = [{ name: "Fallback" }]; - - // Set up space cell with an empty default pattern - const spaceCell = runtime.getCell(space, space).withTx(tx); - const defaultPatternCell = runtime.getCell(space, "default-pattern").withTx( - tx, - ); - defaultPatternCell.set({}); // Empty default pattern - (spaceCell as any).key("defaultPattern").set(defaultPatternCell); - - await tx.commit(); - await runtime.idle(); - tx = runtime.edit(); - - const wishRecipe = recipe("wish default", () => { - const missing = wish("/missing", fallback); - return { missing }; - }); - - const resultCell = runtime.getCell<{ missing?: unknown }>( - space, - "wish built-in default", - undefined, - tx, - ); - const result = runtime.run(tx, wishRecipe, {}, resultCell); - await tx.commit(); - await runtime.idle(); - tx = runtime.edit(); - - await runtime.idle(); - - expect(result.key("missing").get()).toEqual(fallback); - }); }); From a8395fe081ee841b7aceb40a1206e2baa229465b Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Fri, 14 Nov 2025 09:21:18 +1000 Subject: [PATCH 3/3] Switch from per-charm tools to top-level run/read/inspect tools (#2072) * Switch from per-charm tools to top-level run/read/inspect tools * Fix tool schemas/parameter declarations * Append schema LUT to the system prompt Makes this a 1:1 comparison against old version * Lint + format * Fix test case --- packages/patterns/chatbot.tsx | 25 +- packages/runner/src/builtins/llm-dialog.ts | 513 +++++++++++------- .../runner/test/llm-dialog-helpers.test.ts | 52 +- 3 files changed, 351 insertions(+), 239 deletions(-) diff --git a/packages/patterns/chatbot.tsx b/packages/patterns/chatbot.tsx index 746b86e0ba..74f1818e65 100644 --- a/packages/patterns/chatbot.tsx +++ b/packages/patterns/chatbot.tsx @@ -328,21 +328,23 @@ export default recipe( return attachments; }); - // Derive tools from attachments (including auto-attached recent charm) + // Surface attached charms so the LLM can use read/run/schema helpers. const dynamicTools = computed(() => { - const tools: Record = {}; + const attached: Record = {}; for (const attachment of attachmentsWithRecent || []) { - if (attachment.type === "mention" && attachment.charm) { - const charmName = attachment.charm[NAME] || "Charm"; - tools[charmName] = { - charm: attachment.charm, - description: `Handlers from ${charmName}`, - }; - } + if (attachment.type !== "mention" || !attachment.charm) continue; + const charmName = attachment.charm[NAME] || "Charm"; + attached[charmName] = { + charm: attachment.charm, + description: + `Attached charm ${charmName}. Use schema("${charmName}") to ` + + `inspect it, then read("${charmName}/path") or ` + + `run("${charmName}/handler").`, + }; } - return tools; + return attached; }); const attachmentTools = { @@ -354,7 +356,8 @@ export default recipe( }), }, listAttachments: { - description: "List all attachments in the attachments array.", + description: + "List attachment names to use with schema(), read(), and run().", handler: listAttachments({ allAttachments: attachmentsWithRecent }), }, listMentionable: { diff --git a/packages/runner/src/builtins/llm-dialog.ts b/packages/runner/src/builtins/llm-dialog.ts index 7cc11de6fc..214e817bf6 100644 --- a/packages/runner/src/builtins/llm-dialog.ts +++ b/packages/runner/src/builtins/llm-dialog.ts @@ -35,18 +35,6 @@ const logger = getLogger("llm-dialog", { const client = new LLMClient(); const REQUEST_TIMEOUT = 1000 * 60 * 5; // 5 minutes -/** - * Slugifies a string to match the pattern ^[a-zA-Z0-9_-]{1,128}$ - * Replaces spaces and invalid characters with underscores, truncates to 128 chars - */ -function slugify(str: string): string { - return str - .replace(/[^a-zA-Z0-9_-]/g, "_") - .replace(/_{2,}/g, "_") - .replace(/^_|_$/g, "") - .slice(0, 128); -} - /** * Remove the injected `result` field from a JSON schema so tools don't * advertise it as an input parameter. @@ -114,15 +102,6 @@ async function getCharmResultSchemaAsync( } } -function stringifySchemaGuarded(schema: JSONSchema | undefined): string { - try { - const s = JSON.stringify(schema ?? {}); - return s.length > 4000 ? s.slice(0, 4000) + "…" : s; - } catch { - return "{}"; - } -} - function getLoadedRecipeResultSchema( runtime: IRuntime | undefined, charm: Cell, @@ -266,15 +245,10 @@ type CharmToolEntry = { charmName: string; }; -type AggregatedCharmToolMeta = { - kind: "read" | "run"; - charm: Cell; -}; - type ToolCatalog = { llmTools: Record; legacyToolCells: Map>>; - aggregatedTools: Map; + charmMap: Map>; }; function collectToolEntries( @@ -305,59 +279,106 @@ function collectToolEntries( return { legacy, charms }; } -function createCharmToolDefinitions( - charmName: string, - schemaString: string, -): { - read: { name: string; description: string; inputSchema: JSONSchema }; - run: { name: string; description: string; inputSchema: JSONSchema }; -} { - const slug = slugify(charmName); - const readName = `${slug}_read`; - const runName = `${slug}_run`; - - const readDescription = - `Read values from charm "${charmName}" using path: string[]. ` + - `Construct paths by walking the charm schema (single key -> ["key"]). ` + - `Schema: ${schemaString}`; - - const runDescription = - `Run handlers on charm "${charmName}" using path: string[] ` + - `to a handler stream and args: object. You may pass args nested ` + - `under input.args or as top-level fields (path removed). ` + - `Schema: ${schemaString}`; - - const readInputSchema: JSONSchema = { - type: "object", - properties: { - path: { type: "array", items: { type: "string" }, minItems: 1 }, - }, - required: ["path"], - additionalProperties: false, - }; +const READ_TOOL_NAME = "read"; +const RUN_TOOL_NAME = "run"; +const SCHEMA_TOOL_NAME = "schema"; - const runInputSchema: JSONSchema = { - type: "object", - properties: { - path: { type: "array", items: { type: "string" }, minItems: 1 }, - args: { type: "object" }, +const READ_INPUT_SCHEMA: JSONSchema = { + type: "object", + properties: { + path: { + type: "string", + description: "Target path in the form Charm/child/grandchild.", }, - required: ["path"], - additionalProperties: false, - }; + }, + required: ["path"], + additionalProperties: false, +}; - return { - read: { - name: readName, - description: readDescription, - inputSchema: readInputSchema, +const RUN_INPUT_SCHEMA: JSONSchema = { + type: "object", + properties: { + path: { + type: "string", + description: "Target handler path in the form Charm/handler/path.", }, - run: { - name: runName, - description: runDescription, - inputSchema: runInputSchema, + args: { + type: "object", + description: "Arguments passed to the handler.", }, - }; + }, + required: ["path"], + additionalProperties: true, +}; + +const SCHEMA_INPUT_SCHEMA: JSONSchema = { + type: "object", + properties: { + path: { + type: "string", + description: "Name of the attached charm.", + }, + }, + required: ["path"], + additionalProperties: false, +}; + +function ensureString( + value: unknown, + field: string, + example: string, +): string { + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error( + `${field} must be a non-empty string, e.g. "${example}".`, + ); + } + return trimmed; + } + throw new Error(`${field} must be a string, e.g. "${example}".`); +} + +function extractStringField( + input: unknown, + field: string, + example: string, +): string { + if (typeof input === "string") { + return ensureString(input, field, example); + } + if (input && typeof input === "object") { + const value = (input as Record)[field]; + return ensureString(value, field, example); + } + throw new Error(`${field} must be a non-empty string, e.g. "${example}".`); +} + +function parseTargetString( + target: string, +): { charmName: string; pathSegments: string[] } { + const cleaned = target.split("/").map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + if (cleaned.length === 0) { + throw new Error( + 'target must include a charm name, e.g. "Charm/path".', + ); + } + const [charmName, ...pathSegments] = cleaned; + return { charmName, pathSegments }; +} + +function extractRunArguments(input: unknown): Record { + if (input && typeof input === "object") { + const obj = input as Record; + if (obj.args && typeof obj.args === "object") { + return obj.args as Record; + } + const { path: _path, ...rest } = obj; + return rest as Record; + } + return {}; } /** @@ -370,7 +391,7 @@ function createCharmToolDefinitions( */ function flattenTools( toolsCell: Cell, - runtime?: IRuntime, + _runtime?: IRuntime, ): Record< string, { @@ -393,42 +414,46 @@ function flattenTools( flattened[entry.name] = passThrough; } - for (const entry of charms) { - let schema: JSONSchema | undefined = - getLoadedRecipeResultSchema(runtime, entry.charm) ?? - buildMinimalSchemaFromValue(entry.charm); - schema = schema ?? ({} as JSONSchema); - const schemaString = stringifySchemaGuarded(schema); - const charmTools = createCharmToolDefinitions( - entry.charmName, - schemaString, - ); - - flattened[charmTools.read.name] = { - description: charmTools.read.description, - inputSchema: charmTools.read.inputSchema, - internal: { kind: "cell", path: [], charmName: entry.charmName }, + if (charms.length > 0) { + const names = charms.map((entry) => entry.charmName); + const list = names.join(", "); + const availability = list + ? `Available charms: ${list}.` + : "No charms attached."; + + flattened[READ_TOOL_NAME] = { + description: + "Read data from an attached charm using a target path like " + + '"Charm/result/path". ' + availability, + inputSchema: READ_INPUT_SCHEMA, }; - - flattened[charmTools.run.name] = { - description: charmTools.run.description, - inputSchema: charmTools.run.inputSchema, - internal: { kind: "handler", path: [], charmName: entry.charmName }, + flattened[RUN_TOOL_NAME] = { + description: + "Invoke a handler on an attached charm. Provide the target " + + 'path like "Charm/handlers/doThing" plus args if required. ' + + availability, + inputSchema: RUN_INPUT_SCHEMA, }; + // flattened[SCHEMA_TOOL_NAME] = { + // description: + // "Return the JSON schema for an attached charm to understand " + + // "available fields and handlers. " + availability, + // inputSchema: SCHEMA_INPUT_SCHEMA, + // }; } return flattened; } -async function buildToolCatalog( - runtime: IRuntime, +function buildToolCatalog( + _runtime: IRuntime, toolsCell: Cell, -): Promise { +): ToolCatalog { const { legacy, charms } = collectToolEntries(toolsCell); const llmTools: ToolCatalog["llmTools"] = {}; const legacyToolCells = new Map>>(); - const aggregatedTools = new Map(); + const charmMap = new Map>(); for (const entry of legacy) { const toolValue: any = entry.tool ?? {}; @@ -444,61 +469,79 @@ async function buildToolCatalog( legacyToolCells.set(entry.name, entry.cell); } + const charmNames: string[] = []; for (const entry of charms) { - const schema = await getCharmResultSchemaAsync(runtime, entry.charm) ?? {}; - const schemaString = stringifySchemaGuarded(schema as JSONSchema); - const charmTools = createCharmToolDefinitions( - entry.charmName, - schemaString, - ); + charmMap.set(entry.charmName, entry.charm); + charmNames.push(entry.charmName); + } - llmTools[charmTools.read.name] = { - description: charmTools.read.description, - inputSchema: charmTools.read.inputSchema, + if (charmNames.length > 0) { + const list = charmNames.join(", "); + const availability = list + ? `Available charms: ${list}.` + : "No charms attached."; + + llmTools[READ_TOOL_NAME] = { + description: "Read data from an attached charm using a path like " + + '"Charm/result/path". Charm schemas are provided in the system prompt. ' + + availability, + inputSchema: READ_INPUT_SCHEMA, }; - llmTools[charmTools.run.name] = { - description: charmTools.run.description, - inputSchema: charmTools.run.inputSchema, + llmTools[RUN_TOOL_NAME] = { + description: + "Run a handler on an attached charm. Provide the path like " + + '"Charm/handlers/doThing" and optionally args. Charm schemas are ' + + "provided in the system prompt. " + availability, + inputSchema: RUN_INPUT_SCHEMA, + }; + llmTools[SCHEMA_TOOL_NAME] = { + description: + "Return the JSON schema for an attached charm to discover its " + + "fields and handlers. Note: schemas are also provided in the system " + + "prompt for convenience. " + availability, + inputSchema: SCHEMA_INPUT_SCHEMA, }; - - aggregatedTools.set(charmTools.read.name, { - kind: "read", - charm: entry.charm, - }); - aggregatedTools.set(charmTools.run.name, { - kind: "run", - charm: entry.charm, - }); } - return { llmTools, legacyToolCells, aggregatedTools }; + return { llmTools, legacyToolCells, charmMap }; } -function normalizeCharmPathSegments(input: unknown): string[] { - const rawPath = (input && typeof input === "object") - ? (input as any).path - : undefined; - const parts = Array.isArray(rawPath) - ? rawPath.map((segment) => String(segment)) - : []; - if (parts.length === 0) { - throw new Error("path must be an array of strings"); +/** + * Build a formatted documentation string describing all attached charm schemas. + * This is appended to the system prompt so the LLM has immediate context about + * available charms without needing to call schema() first. + */ +async function buildCharmSchemasDocumentation( + runtime: IRuntime, + charmMap: Map>, +): Promise { + if (charmMap.size === 0) { + return ""; } - return parts.filter((segment) => - segment !== undefined && segment !== null && `${segment}`.length > 0 - ).map((segment) => segment.toString()); -} -function extractRunArguments(input: unknown): Record { - if (input && typeof input === "object") { - const obj = input as Record; - if (obj.args && typeof obj.args === "object") { - return obj.args as Record; + const schemaEntries: string[] = []; + + for (const [charmName, charm] of charmMap.entries()) { + try { + const schema = await getCharmResultSchemaAsync(runtime, charm); + if (schema) { + const schemaJson = JSON.stringify(schema, null, 2); + schemaEntries.push( + `## ${charmName}\n\`\`\`json\n${schemaJson}\n\`\`\``, + ); + } + } catch (e) { + logger.warn(`Failed to get schema for charm ${charmName}:`, e); } - const { path: _path, ...rest } = obj; - return rest as Record; } - return {}; + + if (schemaEntries.length === 0) { + return ""; + } + + return `\n\n# Attached Charm Schemas\n\nThe following charms are attached and available via read() and run() tools:\n\n${ + schemaEntries.join("\n\n") + }`; } function resolveToolCall( @@ -510,10 +553,11 @@ function resolveToolCall( toolDef?: Cell>; charmMeta?: { handler?: any; - cell?: Cell; charm: Cell; extraParams?: Record; pattern?: Readonly; + mode: "read" | "run" | "schema"; + targetSegments?: string[]; }; } { const name = toolCallPart.toolName; @@ -526,62 +570,101 @@ function resolveToolCall( }; } - const aggregated = catalog.aggregatedTools.get(name); - if (!aggregated) { - throw new Error("Tool has neither pattern nor handler"); - } - - const segments = normalizeCharmPathSegments(toolCallPart.input); - const baseLink = aggregated.charm.getAsNormalizedFullLink(); - const link = { - ...baseLink, - path: [ - ...baseLink.path, - ...segments.map((segment) => segment.toString()), - ], - }; + if ( + name === READ_TOOL_NAME || name === RUN_TOOL_NAME || + name === SCHEMA_TOOL_NAME + ) { + if (catalog.charmMap.size === 0) { + throw new Error("No charm attachments available."); + } + if (name === SCHEMA_TOOL_NAME) { + const charmName = extractStringField( + toolCallPart.input, + "path", + "Charm", + ); + const charm = catalog.charmMap.get(charmName); + if (!charm) { + throw new Error( + `Unknown charm "${charmName}". Use listAttachments for options.`, + ); + } + return { + charmMeta: { charm, mode: "schema" }, + call: { id, name, input: { charm: charmName } }, + }; + } - if (aggregated.kind === "read") { - const maybeRef: Cell = runtime.getCellFromLink(link); - if (isStream(maybeRef)) { - throw new Error("path resolves to a handler stream; use _run"); + const target = extractStringField( + toolCallPart.input, + "path", + "Charm/path", + ); + const { charmName, pathSegments } = parseTargetString(target); + const charm = catalog.charmMap.get(charmName); + if (!charm) { + throw new Error( + `Unknown charm "${charmName}". Use listAttachments for options.`, + ); } - return { - charmMeta: { cell: aggregated.charm, charm: aggregated.charm }, - call: { id, name, input: toolCallPart.input }, + const baseLink = charm.getAsNormalizedFullLink(); + const link = { + ...baseLink, + path: [ + ...baseLink.path, + ...pathSegments.map((segment) => segment.toString()), + ], }; - } - const ref: Cell = runtime.getCellFromLink(link); - if (isStream(ref)) { - return { - charmMeta: { handler: ref as any, charm: aggregated.charm }, - call: { - id, - name, - input: extractRunArguments(toolCallPart.input), - }, - }; - } + if (name === READ_TOOL_NAME) { + const ref = runtime.getCellFromLink(link); + if (isStream(ref)) { + throw new Error('path resolves to a handler; use run("Charm/path").'); + } + return { + charmMeta: { + charm, + mode: "read", + targetSegments: pathSegments, + }, + call: { id, name, input: { path: target } }, + }; + } - const pattern = (ref as Cell).key("pattern") - .getRaw() as unknown as Readonly | undefined; - if (pattern) { - return { - charmMeta: { - pattern, - extraParams: (ref as Cell).key("extraParams").get() ?? {}, - charm: aggregated.charm, - }, - call: { - id, - name, - input: extractRunArguments(toolCallPart.input), - }, - }; + const ref: Cell = runtime.getCellFromLink(link); + if (isStream(ref)) { + return { + charmMeta: { handler: ref as any, charm, mode: "run" }, + call: { + id, + name, + input: extractRunArguments(toolCallPart.input), + }, + }; + } + + const pattern = (ref as Cell).key("pattern") + .getRaw() as unknown as Readonly | undefined; + if (pattern) { + return { + charmMeta: { + pattern, + extraParams: (ref as Cell).key("extraParams").get() ?? {}, + charm, + mode: "run", + }, + call: { + id, + name, + input: extractRunArguments(toolCallPart.input), + }, + }; + } + + throw new Error("target does not resolve to a handler stream or pattern."); } - throw new Error("path does not resolve to a handler stream"); + throw new Error("Tool has neither pattern nor handler"); } function extractToolCallParts( @@ -714,8 +797,8 @@ function createToolResultMessages( } export const llmDialogTestHelpers = { - createCharmToolDefinitions, - normalizeCharmPathSegments, + parseTargetString, + extractStringField, extractRunArguments, extractToolCallParts, buildAssistantMessage, @@ -821,12 +904,27 @@ async function invokeToolCall( toolCall: LLMToolCall, charmMeta?: { handler?: any; - cell?: Cell; charm: Cell; extraParams?: Record; pattern?: Readonly; + mode: "read" | "run" | "schema"; + targetSegments?: string[]; }, ) { + if (charmMeta?.mode === "schema") { + const schema = await getCharmResultSchemaAsync(runtime, charmMeta.charm) ?? + {}; + const value = JSON.parse(JSON.stringify(schema ?? {})); + return { type: "json", value }; + } + + if (charmMeta?.mode === "read") { + const segments = charmMeta.targetSegments ?? []; + const realized = charmMeta.charm.getAsQueryResult(segments); + const value = JSON.parse(JSON.stringify(realized)); + return { type: "json", value }; + } + const pattern = charmMeta?.pattern ?? toolDef?.key("pattern").getRaw() as unknown as | Readonly @@ -838,18 +936,6 @@ async function invokeToolCall( // FIXME(bf): in practice, toolCall has toolCall.toolCallId not .id const result = runtime.getCell(space, toolCall.id); - // Cell tools (aggregated _read): materialize via getAsQueryResult(path) - if (charmMeta?.cell) { - const input = toolCall.input as any; - const pathParts = Array.isArray(input?.path) - ? input.path.map((s: any) => String(s)) - : []; - const realized = charmMeta.cell.getAsQueryResult(pathParts); - // Ensure we return plain JSON by stringifying and parsing - const value = JSON.parse(JSON.stringify(realized)); - return { type: "json", value }; - } - // ensure the charm this handler originates from is actually running if (handler && !pattern && charmMeta) { await ensureSourceCharmRunning(runtime, charmMeta); @@ -1120,10 +1206,17 @@ async function startRequest( // No need to flatten here; UI handles flattened tools reactively - const toolCatalog = await buildToolCatalog(runtime, toolsCell); + const toolCatalog = buildToolCatalog(runtime, toolsCell); + + // Build charm schemas documentation and append to system prompt + const charmSchemasDocs = await buildCharmSchemasDocumentation( + runtime, + toolCatalog.charmMap, + ); + const augmentedSystem = (system ?? "") + charmSchemasDocs; const llmParams: LLMRequest = { - system: system ?? "", + system: augmentedSystem, messages: messagesCell.withTx(tx).get() as BuiltInLLMMessage[], maxTokens: maxTokens, stream: true, diff --git a/packages/runner/test/llm-dialog-helpers.test.ts b/packages/runner/test/llm-dialog-helpers.test.ts index a5339c0b76..8e640dc267 100644 --- a/packages/runner/test/llm-dialog-helpers.test.ts +++ b/packages/runner/test/llm-dialog-helpers.test.ts @@ -3,8 +3,8 @@ import type { BuiltInLLMMessage, BuiltInLLMToolCallPart } from "commontools"; import { llmDialogTestHelpers } from "../src/builtins/llm-dialog.ts"; const { - createCharmToolDefinitions, - normalizeCharmPathSegments, + parseTargetString, + extractStringField, extractRunArguments, extractToolCallParts, buildAssistantMessage, @@ -12,29 +12,45 @@ const { hasValidContent, } = llmDialogTestHelpers; -Deno.test("createCharmToolDefinitions slugifies charm names and returns tool metadata", () => { - const defs = createCharmToolDefinitions("My Charm!", '{ "type": "object" }'); - assertEquals(defs.read.name, "My_Charm_read"); - assertEquals(defs.run.name, "My_Charm_run"); - const readSchema = defs.read.inputSchema as any; - const runSchema = defs.run.inputSchema as any; - assertEquals(readSchema.properties.path.type, "array"); - assertEquals(runSchema.required, ["path"]); - assert(defs.read.description.includes("My Charm!")); +Deno.test("parseTargetString splits charm name and path segments", () => { + const parsed = parseTargetString("Charm/foo/bar"); + assertEquals(parsed.charmName, "Charm"); + assertEquals(parsed.pathSegments, ["foo", "bar"]); }); -Deno.test("normalizeCharmPathSegments returns sanitized segments", () => { - const segments = normalizeCharmPathSegments({ path: ["foo", 1, "bar", ""] }); - assertEquals(segments, ["foo", "1", "bar"]); +Deno.test("parseTargetString handles whitespace and empty segments", () => { + const parsed = parseTargetString(" Charm / foo / "); + assertEquals(parsed.charmName, "Charm"); + assertEquals(parsed.pathSegments, ["foo"]); }); -Deno.test("normalizeCharmPathSegments throws when path is missing", () => { - assertThrows(() => normalizeCharmPathSegments({})); +Deno.test("parseTargetString throws when charm name missing", () => { + assertThrows(() => parseTargetString(" ")); +}); + +Deno.test("extractStringField returns value from string input", () => { + assertEquals( + extractStringField("Charm/path", "path", "Charm/path"), + "Charm/path", + ); +}); + +Deno.test("extractStringField returns value from object field", () => { + const value = extractStringField( + { charm: "Charm" }, + "charm", + "Charm", + ); + assertEquals(value, "Charm"); +}); + +Deno.test("extractStringField throws on missing field", () => { + assertThrows(() => extractStringField({ wrong: "Charm" }, "charm", "Charm")); }); Deno.test("extractRunArguments prioritizes nested args object", () => { const args = extractRunArguments({ - path: ["demo"], + path: "Charm/run", args: { foo: "bar" }, extra: 1, }); @@ -43,7 +59,7 @@ Deno.test("extractRunArguments prioritizes nested args object", () => { Deno.test("extractRunArguments removes path key when no args provided", () => { const args = extractRunArguments({ - path: ["demo"], + path: "Charm/run", mode: "test", }); assertEquals(args, { mode: "test" });