diff --git a/.changeset/strict-scalar-tool-envelope.md b/.changeset/strict-scalar-tool-envelope.md new file mode 100644 index 00000000..1c50b16f --- /dev/null +++ b/.changeset/strict-scalar-tool-envelope.md @@ -0,0 +1,5 @@ +--- +"ignite-element": patch +--- + +Tighten igniteTools scalar provider envelopes by publishing `additionalProperties: false` on scalar wrappers and rejecting malformed `{ value, ...extra }` provider inputs as `InvalidInput`. diff --git a/.fas/TASKS.md b/.fas/TASKS.md index e4be36e0..167c6043 100644 --- a/.fas/TASKS.md +++ b/.fas/TASKS.md @@ -1562,7 +1562,7 @@ No active tasks. - Title: collapse ignite ActorWebAddress tolerant union to pure string once @actor-web/runtime publishes the opaque branded addre - Mode: single-agent -- Status: queued +- Status: deferred - Owner: runtime - Brief: .fas/tasks/collapse-ignite-actorwebaddress-tolerant-union-to-pure-strin.md - Automation mode: advisory @@ -1571,9 +1571,12 @@ No active tasks. - Title: update ActorWebAdapter dev-warning wording that still references the removed commandSource config concept after the beta - Mode: single-agent -- Status: queued -- Owner: runtime +- Status: done +- Owner: implementer - Brief: .fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md +- Verification lane: fast +- Policy sensitivity: standard +- Blast radius: cross-cutting ### Task: add an observe() observation channel to the neutral igniteTools core (events + view stream) for the agent act-observe lo @@ -1638,8 +1641,8 @@ No active tasks. - Title: Split the agent-runtime host type (EventTarget) from the render-host Host generic so the headless runtime needs no HTMLE - Mode: single-agent -- Status: queued -- Owner: runtime +- Status: done +- Owner: implementer - Brief: .fas/tasks/split-the-agent-runtime-host-type-eventtarget-from-the-rende.md - Automation mode: advisory @@ -1647,10 +1650,13 @@ No active tasks. - Title: igniteTools PR2 CodeRabbit follow-ups — bind runtime.execute, strict scalar value envelope, fix canExecute doc wording - Mode: single-agent -- Status: queued -- Owner: runtime +- Status: done +- Owner: implementer - Brief: .fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md - Automation mode: advisory +- Verification lane: fast +- Policy sensitivity: standard +- Blast radius: cross-cutting ### Task: Fix example runtime test lanes before next igniteTools task diff --git a/.fas/queue/tasks.json b/.fas/queue/tasks.json index 28318240..67319389 100644 --- a/.fas/queue/tasks.json +++ b/.fas/queue/tasks.json @@ -187,7 +187,7 @@ "taskClassification": "self-improvement", "selfImprovementCategory": "developer-experience", "signalFingerprint": null, - "status": "deferred", + "status": "queued", "owner": "runtime", "mode": "single-agent", "dependsOn": [ @@ -202,7 +202,7 @@ "prDependencies": [], "createdAt": "2026-06-18T17:54:24.107Z", "updatedAt": "2026-06-20T07:07:22.721Z", - "deferredReason": "dependency_reopened: task-1782269414711", + "deferredReason": null, "invalidationReason": null }, { @@ -403,81 +403,8 @@ "branchBase": null, "prDependencies": [], "createdAt": "2026-06-24T02:49:56.312Z", - "updatedAt": "2026-06-24T02:49:56.312Z", - "deferredReason": "dependency_reopened: task-1782499305182", - "invalidationReason": null - }, - { - "id": "task-1782269414711", - "task": "update ActorWebAdapter dev-warning wording that still references the removed commandSource config concept after the beta", - "priority": "low", - "source": "manual", - "details": "After commandSource was removed (PR #67), these messages still say 'commandSource' which no longer exists in the public API: failInvariant 'Actor-Web commandSource is required.' at packages/ignite-adapters/src/adapters/ActorWebAdapter.ts ~289 and ~327 (resolveCommandActor), and the send() console.warn 'Cannot send events without an Actor-Web commandSource.' ~468. Reword to reference 'a command-capable source (one that exposes send())'. Internal dev warnings only; behavior unchanged. Nit deferred from PR #67 babysit to avoid churning a green PR.\nSee .fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md", - "taskClassification": "standard", - "selfImprovementCategory": null, - "signalFingerprint": null, - "status": "deferred", - "owner": "runtime", - "mode": "single-agent", - "dependsOn": [ - "task-1782269396312" - ], - "blocks": [ - "task-1781805264107" - ], - "branchBase": null, - "prDependencies": [], - "createdAt": "2026-06-24T02:50:14.711Z", - "updatedAt": "2026-06-24T02:50:14.711Z", - "deferredReason": "dependency_reopened: task-1782269396312", - "invalidationReason": null - }, - { - "id": "task-1782499303572", - "task": "Split the agent-runtime host type (EventTarget) from the render-host Host generic so the headless runtime needs no HTMLE", - "priority": "medium", - "source": "manual", - "details": "Phase A made the headless runtime DOM-free via createRuntimeHost returning a bare EventTarget cast to HTMLElement (IgniteElementFactory.ts). The cast is forced because the runtime host shares the render path's Host generic (typed HTMLElement for the JSX renderer host.appendChild). The agent runtime only uses host as an EventTarget (on() add/removeEventListener; effect emits via dispatchEvent) — verified no element-only API usage on the runtime host. Properly split the runtime-host type (EventTarget) from the render-host Host generic (HTMLElement) across createComponentFactory/createProjectionFactory/agent.ts so createRuntimeHost returns EventTarget honestly and the cast disappears. Found by CodeRabbit on the Phase A PR (major, accepted-with-justification + this follow-up).\nSee .fas/tasks/split-the-agent-runtime-host-type-eventtarget-from-the-rende.md", - "taskClassification": "self-improvement", - "selfImprovementCategory": "runtime-reliability", - "signalFingerprint": null, - "status": "queued", - "owner": "runtime", - "mode": "single-agent", - "dependsOn": [ - "task-1782494866385" - ], - "blocks": [ - "task-1782499305182" - ], - "branchBase": null, - "prDependencies": [], - "createdAt": "2026-06-26T18:41:43.572Z", - "updatedAt": "2026-06-26T18:41:43.572Z" - }, - { - "id": "task-1782499305182", - "task": "igniteTools PR2 CodeRabbit follow-ups — bind runtime.execute, strict scalar value envelope, fix canExecute doc wording", - "priority": "medium", - "source": "manual", - "details": "Three CodeRabbit findings on shipped PR2 code, out of the Phase A (DOM-free) scope. (1) igniteTools.ts: const execute = runtime.execute is unbound — bind it via runtime.execute.bind(runtime) like canExecute already is, else an execute that relies on this breaks when run() calls it; the current fake runtime uses an arrow so the gap is uncovered — add a class/this-based fake runtime regression test. (2) tools/scalar.ts: the scalar value envelope is not strict — { value: 7, extra: true } unwraps to 7, bypassing resolveCall validation; add additionalProperties:false on encode (toProviderInputSchema) and reject extra keys on decode (fromProviderInput) so a malformed provider envelope surfaces InvalidInput; add tests. (3) docs/ignite-tools.md: the observation note overpromises that canExecute re-gates the tool list as state changes, but igniteTools snapshots the manifest once — reword to say rebuild igniteTools() (or re-derive tools) to publish a fresh canExecute-gated manifest.\nSee .fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md", - "taskClassification": "self-improvement", - "selfImprovementCategory": "runtime-reliability", - "signalFingerprint": null, - "status": "deferred", - "owner": "runtime", - "mode": "single-agent", - "dependsOn": [ - "task-1782499303572" - ], - "blocks": [ - "task-1782269396312" - ], - "branchBase": null, - "prDependencies": [], - "createdAt": "2026-06-26T18:41:45.182Z", - "updatedAt": "2026-06-26T18:41:45.182Z", - "deferredReason": "dependency_reopened: task-1782499303572", + "updatedAt": "2026-07-03T17:13:42.000Z", + "deferredReason": "blocked-until-actor-web-runtime-branded-address: current @actor-web/runtime resolves to 0.1.0, which still uses the legacy object address", "invalidationReason": null } ], @@ -2784,6 +2711,75 @@ "workflowId": "direct-1783093106382", "branchName": "fas/ignite-ecosystem-bridge", "prStatus": "draft-only" + }, + { + "id": "task-1782499303572", + "task": "Split the agent-runtime host type (EventTarget) from the render-host Host generic so the headless runtime needs no HTMLE", + "priority": "medium", + "source": "manual", + "taskClassification": "self-improvement", + "selfImprovementCategory": "runtime-reliability", + "signalFingerprint": null, + "dependsOn": [ + "task-1782494866385" + ], + "blocks": [ + "task-1782499305182" + ], + "branchBase": null, + "prDependencies": [], + "prRef": null, + "completedAt": "2026-07-03T17:03:43.953Z", + "terminalOutcome": null, + "workflowId": "direct-1783097767418", + "branchName": "fas/ignite-ecosystem-followups", + "prStatus": "draft-only" + }, + { + "id": "task-1782499305182", + "task": "igniteTools PR2 CodeRabbit follow-ups — bind runtime.execute, strict scalar value envelope, fix canExecute doc wording", + "priority": "medium", + "source": "manual", + "taskClassification": "self-improvement", + "selfImprovementCategory": "runtime-reliability", + "signalFingerprint": null, + "dependsOn": [ + "task-1782499303572" + ], + "blocks": [ + "task-1782269396312" + ], + "branchBase": null, + "prDependencies": [], + "prRef": null, + "completedAt": "2026-07-03T17:25:38.445Z", + "terminalOutcome": null, + "workflowId": "direct-1783098275694", + "branchName": "fas/ignite-ecosystem-followups", + "prStatus": "draft-only" + }, + { + "id": "task-1782269414711", + "task": "update ActorWebAdapter dev-warning wording that still references the removed commandSource config concept after the beta", + "priority": "low", + "source": "manual", + "taskClassification": "standard", + "selfImprovementCategory": null, + "signalFingerprint": null, + "dependsOn": [ + "task-1782269396312" + ], + "blocks": [ + "task-1781805264107" + ], + "branchBase": null, + "prDependencies": [], + "prRef": null, + "completedAt": "2026-07-03T17:18:57.748Z", + "terminalOutcome": null, + "workflowId": "direct-1783098943999", + "branchName": "fas/ignite-ecosystem-followups", + "prStatus": "draft-only" } ], "pullRequests": [], diff --git a/.fas/tasks/collapse-ignite-actorwebaddress-tolerant-union-to-pure-strin.md b/.fas/tasks/collapse-ignite-actorwebaddress-tolerant-union-to-pure-strin.md index f218fb9f..80cc4c38 100644 --- a/.fas/tasks/collapse-ignite-actorwebaddress-tolerant-union-to-pure-strin.md +++ b/.fas/tasks/collapse-ignite-actorwebaddress-tolerant-union-to-pure-strin.md @@ -48,7 +48,10 @@ Drop the object branch + the TODO(actor-web > 0.1.0) comment in packages/ignite- - Validate generated scope, acceptance criteria, and verification evidence before closeout to avoid workflow drift. ## Dependencies -- None known at task creation. +- Blocked until `@actor-web/runtime` publishes the branded string `ActorAddress` + and this repo bumps the installed/devDependency version. Current verification + on 2026-07-03 shows `@ignite-element/adapters` still resolves + `@actor-web/runtime@0.1.0`, which uses the legacy object address. ## Open questions - None captured at task creation. diff --git a/.fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md b/.fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md index 623bad47..7511e836 100644 --- a/.fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md +++ b/.fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md @@ -37,9 +37,37 @@ Three CodeRabbit findings on shipped PR2 code, out of the Phase A (DOM-free) sco - packages/ignite-element/src/tests/tools.test.ts - packages/ignite-element/src/tests/tools.scalar.test.ts - docs/ignite-tools.md +- .changeset/strict-scalar-tool-envelope.md +- packages/ignite-element/src/tests/tools.anthropic.test.ts ## Scope Amendments -- None. +- Type: implementation-scope +- Added at: 2026-07-03 +- Trigger: strict scalar envelope requires package changeset and Anthropic golden fixture update +- Reason: Acceptance criteria require a changeset, and adding additionalProperties:false changes the provider-facing Anthropic schema fixture. +- Evidence source: closeout-readiness +- Evidence: closeout-readiness | .fas/state/closeout-readiness/latest.json +- Accuracy signal: live ChangeSet after focused tests +- Follow-up needed: none + +- Type: implementation-scope +- Added at: 2026-07-03 +- Trigger: strict scalar wrapper changed provider schema +- Reason: The package patch changeset is required by acceptance criteria, and the Anthropic golden test must reflect additionalProperties:false emitted by the shared scalar helper. +- Added paths: .changeset/strict-scalar-tool-envelope.md, packages/ignite-element/src/tests/tools.anthropic.test.ts +- Evidence source: focused-verification +- Evidence: focused-verification | .fas/state/verification/validate-task-1783098491.log +- Accuracy signal: focused tests and typecheck passed before scope refresh +- Follow-up needed: none + +- Type: implementation-scope +- Added at: 2026-07-03 +- Trigger: explicit affected-file amendment recorded +- Reason: Refresh generated planning and task packet after adding changeset and Anthropic fixture to the task scope. +- Evidence source: task-brief +- Evidence: task-brief | .fas/tasks/ignitetools-pr2-coderabbit-follow-ups-bind-runtime-execute-s.md +- Accuracy signal: affected files now match live ChangeSet +- Follow-up needed: none ## Implementation plan - Convert the supplied context into a scoped implementation plan before editing. diff --git a/.fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md b/.fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md index 0745b189..e98c3e93 100644 --- a/.fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md +++ b/.fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md @@ -23,9 +23,27 @@ After commandSource was removed (PR #67), these messages still say 'commandSourc ## Affected files - packages/ignite-adapters/src/adapters/ActorWebAdapter.ts +- packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts ## Scope Amendments -- None. +- Type: test-scope +- Added at: 2026-07-03 +- Trigger: warning wording needs runtime-facing regression coverage +- Reason: The adapter warning/error strings are exercised through the existing ignite-element ActorWebAdapter vitest suite. +- Added paths: packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts +- Evidence source: focused-test +- Evidence: focused-test | packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts +- Accuracy signal: focused test failed before implementation and passed after wording update +- Follow-up needed: none + +- Type: test-scope +- Added at: 2026-07-03 +- Trigger: explicit affected test path recorded +- Reason: Refresh generated planning and task packet after adding ActorWebAdapter.test.ts to task scope. +- Evidence source: task-brief +- Evidence: task-brief | .fas/tasks/update-actorwebadapter-dev-warning-wording-that-still-refere.md +- Accuracy signal: affected files now match live ChangeSet +- Follow-up needed: none ## Implementation plan - Convert the supplied context into a scoped implementation plan before editing. diff --git a/docs/ignite-tools.md b/docs/ignite-tools.md index 80cf926a..48287409 100644 --- a/docs/ignite-tools.md +++ b/docs/ignite-tools.md @@ -83,14 +83,16 @@ because that is the command's true contract (`getSchema()` must not lie). So the wrap/unwrap lives only at the provider boundary, in shared pure helpers (`tools/scalar.ts`): -- `toProviderInputSchema(schema)` — wraps a scalar under a clean `value` key - (`{ type: "object", properties: { value: schema }, required: ["value"] }`); - object/no-arg schemas pass through unchanged. Adapters call it in `tools()`. -- `fromProviderInput(input, schema)` — unwraps the model's `{ value: x }` back to - `x`, **gated on the manifest schema being scalar** (collision-free: an object - command that legitimately has its own `value` field is never unwrapped). - Adapters call it in `toolCalls()`, which is why the port hands `toolCalls` the - manifest. +- `toProviderInputSchema(schema)` — wraps a scalar under a clean, strict `value` + key (`{ type: "object", properties: { value: schema }, required: ["value"], + additionalProperties: false }`); object/no-arg schemas pass through unchanged. + Adapters call it in `tools()`. +- `fromProviderInput(input, schema)` — unwraps the model's exact `{ value: x }` + back to `x`, **gated on the manifest schema being scalar** (collision-free: an + object command that legitimately has its own `value` field is never unwrapped). + Extra keys keep the provider object intact so `resolveCall` reports + `InvalidInput`. Adapters call it in `toolCalls()`, which is why the port hands + `toolCalls` the manifest. The constraint is universal across providers, so it is fixed once in the port + two helpers; the OpenAI/Ollama dialect reuses them verbatim. @@ -216,7 +218,10 @@ to the provider's `tool_result` (`is_error: true`) so the model can recover. - **typed-view** ✓ + **`getSchema().view`** ✓ (done) — typed manifest inputs + view grounding. - **`canExecute`** (`docs/can-execute.md`) — composes for availability-gated tools - by omitting unavailable commands from the manifest. Older runtimes without the + by omitting unavailable commands when `igniteTools(runtime)` builds the manifest + and by re-checking availability when `run()` routes a call. To publish a fresh + provider tool list after state changes, rebuild `igniteTools(runtime)` or + re-derive provider tools from a fresh manifest. Older runtimes without the optional method still offer all commands for compatibility. ## Alternatives considered diff --git a/packages/ignite-adapters/src/adapters/ActorWebAdapter.ts b/packages/ignite-adapters/src/adapters/ActorWebAdapter.ts index f0f2f438..05b42bf1 100644 --- a/packages/ignite-adapters/src/adapters/ActorWebAdapter.ts +++ b/packages/ignite-adapters/src/adapters/ActorWebAdapter.ts @@ -286,7 +286,9 @@ function createSharedFactory< factory.resolveStateSnapshot = () => entry.snapshot(); factory.resolveCommandActor = () => entry.actor ?? - failInvariant("[ActorWebAdapter] Actor-Web commandSource is required."); + failInvariant( + "[ActorWebAdapter] Actor-Web command-capable source (one that exposes send()) is required.", + ); return factory; } @@ -324,7 +326,9 @@ function createIsolatedFactory< adapter, "[ActorWebAdapter] Unable to resolve actor for facade callbacks.", ).actor ?? - failInvariant("[ActorWebAdapter] Actor-Web commandSource is required.") + failInvariant( + "[ActorWebAdapter] Actor-Web command-capable source (one that exposes send()) is required.", + ) ); }; @@ -465,7 +469,7 @@ function createAdapterEntry< if (!commandSource) { console.warn( - "[ActorWebAdapter] Cannot send events without an Actor-Web commandSource.", + "[ActorWebAdapter] Cannot send events without an Actor-Web command-capable source (one that exposes send()).", ); return; } diff --git a/packages/ignite-element/src/IgniteElementFactory.ts b/packages/ignite-element/src/IgniteElementFactory.ts index d099b435..51751701 100644 --- a/packages/ignite-element/src/IgniteElementFactory.ts +++ b/packages/ignite-element/src/IgniteElementFactory.ts @@ -86,7 +86,7 @@ type FactoryOptions< eventTypes?: readonly string[]; createAdditionalArgs?: ( adapter: IgniteAdapter, - host?: HTMLElement, + host?: EventTarget, ) => AdditionalRenderArgs; resolveView?: (adapter: IgniteAdapter) => RuntimeView; createRenderStrategy?: RenderStrategyFactory; @@ -189,7 +189,7 @@ export default function igniteElementFactory< Event, RenderArgs > | null = null; - let runtimeHost: HTMLElement | null = null; + let runtimeHost: EventTarget | null = null; let lifecycleSequence = 0; let lifecycleInstanceSequence = 0; const lifecycleObservers = new Set< @@ -198,7 +198,7 @@ export default function igniteElementFactory< const createAdditionalArgs: ( adapter: IgniteAdapter, - host?: HTMLElement, + host?: EventTarget, ) => AdditionalRenderArgs = options?.createAdditionalArgs ?? ((_) => ({}) as AdditionalRenderArgs); @@ -327,23 +327,11 @@ export default function igniteElementFactory< sharedInstanceCount = 0; }; - // The headless agent runtime (getSchema/execute/on/watchView) only ever uses - // its host as an EventTarget — `on()` calls host.addEventListener/ - // removeEventListener and effect emits go through host.dispatchEvent. So in a - // non-DOM runtime (Node, edge) it does not need a real element: a bare - // EventTarget keeps the agent runtime fully DOM-free. The render path - // (createRuntimeDomBridge / the custom element) creates its own real element - // and still requires a DOM. - // - // The `as unknown as HTMLElement` cast is forced because the runtime host - // flows through the same `Host` generic the render path uses (typed - // HTMLElement for the JSX renderer's host.appendChild). It is safe here — no - // code path touches the runtime host with element-only APIs (verified) — but - // the proper fix is to split the agent-runtime host type (EventTarget) from - // the render-host `Host` generic so this cast disappears (tracked follow-up). - const createRuntimeHost = (): HTMLElement => + // The headless agent runtime only needs EventTarget APIs for `on()` and + // effect-emitted events. The DOM render path creates its own real element. + const createRuntimeHost = (): EventTarget => typeof document === "undefined" - ? (new EventTarget() as unknown as HTMLElement) + ? new EventTarget() : document.createElement("div"); const createRuntimeDomBridge = ( diff --git a/packages/ignite-element/src/createComponentFactory.ts b/packages/ignite-element/src/createComponentFactory.ts index 343733fb..0e50970c 100644 --- a/packages/ignite-element/src/createComponentFactory.ts +++ b/packages/ignite-element/src/createComponentFactory.ts @@ -41,7 +41,7 @@ export type ElementFactoryOptions< scope?: StateScope; createAdditionalArgs?: ( adapter: IgniteAdapter, - host?: HTMLElement, + host?: EventTarget, ) => AdditionalRenderArgs; createRenderStrategy?: RenderStrategyFactory; eventTypes?: readonly (keyof Events & string)[]; @@ -124,7 +124,7 @@ type BindProjectionToElementsOptions< }; const createDomEmit = ( - host: HTMLElement, + host: EventTarget, ): EmitFromEvents => { return ( type: Type, @@ -187,9 +187,10 @@ export function bindProjectionToElements< `[${errorPrefix}] Host element is required for projection.`, ); } + const renderHost = host as HTMLElement; return projection.createAdditionalArgs( adapter, - host, + renderHost, createDomEmit(host), ); }, diff --git a/packages/ignite-element/src/createProjectionFactory.ts b/packages/ignite-element/src/createProjectionFactory.ts index a9dac834..1ae0ae0e 100644 --- a/packages/ignite-element/src/createProjectionFactory.ts +++ b/packages/ignite-element/src/createProjectionFactory.ts @@ -20,7 +20,7 @@ import { facadeCleanupSymbol, } from "./runtime/effects"; -export type AdapterFactory = (( +export type AdapterFactory = (( host?: Host, ) => IgniteAdapter) & { scope?: StateScope; @@ -46,7 +46,7 @@ export type ProjectionFactoryOptions< >, Additional extends Record = Record, Events extends EventMap = EmptyEventMap, - Host = unknown, + Host = EventTarget, > = { scope?: StateScope; view?: FacadeViewCallback; @@ -87,7 +87,7 @@ export type ProjectionFactory< State, Event, RenderArgs extends BaseRenderArgs, - Host = unknown, + Host = EventTarget, Events extends EventMap = EmptyEventMap, ViewResult extends Record = Record, > = { diff --git a/packages/ignite-element/src/runtime/agent.ts b/packages/ignite-element/src/runtime/agent.ts index 83ec6f93..fefecc55 100644 --- a/packages/ignite-element/src/runtime/agent.ts +++ b/packages/ignite-element/src/runtime/agent.ts @@ -29,7 +29,7 @@ type RuntimeResources< > = { adapter: IgniteAdapter; additionalArgs: AdditionalArgs; - host: HTMLElement; + host: EventTarget; }; type AgentRuntimeOptions< diff --git a/packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts b/packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts index ea2b7b98..ca2e4327 100644 --- a/packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts +++ b/packages/ignite-element/src/tests/adapters/ActorWebAdapter.test.ts @@ -224,6 +224,33 @@ describe("ActorWebAdapter", () => { expect(close).toHaveBeenCalledTimes(1); }); + it("describes missing send() as a missing command-capable source", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const source = createSource() as ReturnType & { + send?: unknown; + }; + const { send: _send, ...readModelSource } = source; + const adapterFactory = createActorWebAdapter( + readModelSource as unknown as ActorWebSource< + ShipmentContext, + ShipmentCommand, + ShipmentEmitted + >, + ); + const adapter = adapterFactory(); + + expect(() => adapterFactory.resolveCommandActor(adapter)).toThrow( + "Actor-Web command-capable source (one that exposes send()) is required.", + ); + + adapter.send({ type: "CREATE_SHIPMENT", shipmentId: "shipment-123" }); + + expect(warn).toHaveBeenCalledWith( + "[ActorWebAdapter] Cannot send events without an Actor-Web command-capable source (one that exposes send()).", + ); + adapter.stop(); + }); + it("dedupes the initial notification when upstream replays synchronously", () => { const source = createSource({ replayOnSubscribe: true, diff --git a/packages/ignite-element/src/tests/tools.anthropic.test.ts b/packages/ignite-element/src/tests/tools.anthropic.test.ts index 9cac7163..7ee135ca 100644 --- a/packages/ignite-element/src/tests/tools.anthropic.test.ts +++ b/packages/ignite-element/src/tests/tools.anthropic.test.ts @@ -47,6 +47,7 @@ describe("anthropic.tools (neutral manifest -> Anthropic tool defs)", () => { type: "object", properties: { value: { type: "number", minimum: 3, maximum: 12 } }, required: ["value"], + additionalProperties: false, }, }); }); diff --git a/packages/ignite-element/src/tests/tools.scalar.test.ts b/packages/ignite-element/src/tests/tools.scalar.test.ts index e5ea0e2a..03b19cb6 100644 --- a/packages/ignite-element/src/tests/tools.scalar.test.ts +++ b/packages/ignite-element/src/tests/tools.scalar.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { IgniteSchemaObject } from "../types/schema"; +import { isErr, resolveCall } from "../tools"; import { fromProviderInput, toProviderInputSchema } from "../tools/scalar"; +import type { IgniteSchemaObject } from "../types/schema"; // Every tool-calling provider (Anthropic / OpenAI / Ollama) requires OBJECT-shaped // tool inputs, but the neutral manifest carries SCALAR inputSchema for single-arg @@ -19,6 +20,7 @@ describe("toProviderInputSchema", () => { type: "object", properties: { value: scalar }, required: ["value"], + additionalProperties: false, }); }); @@ -31,6 +33,7 @@ describe("toProviderInputSchema", () => { type: "object", properties: { value: scalar }, required: ["value"], + additionalProperties: false, }); }); @@ -74,4 +77,21 @@ describe("fromProviderInput", () => { it("returns input unchanged for a scalar schema when the input is not a { value } envelope", () => { expect(fromProviderInput(7, { type: "number" })).toBe(7); }); + + it("rejects a scalar envelope with extra keys by leaving it for command validation", () => { + const input = { value: 7, extra: true }; + const schema: IgniteSchemaObject = { type: "number" }; + const providerInput = fromProviderInput(input, schema); + const result = resolveCall( + [{ name: "setLimit", inputSchema: schema, gated: false }], + "setLimit", + providerInput, + ); + + expect(providerInput).toBe(input); + expect(isErr(result)).toBe(true); + if (isErr(result)) { + expect(result.error.kind).toBe("InvalidInput"); + } + }); }); diff --git a/packages/ignite-element/src/tests/tools.test.ts b/packages/ignite-element/src/tests/tools.test.ts index 754c81e0..f1cf893e 100644 --- a/packages/ignite-element/src/tests/tools.test.ts +++ b/packages/ignite-element/src/tests/tools.test.ts @@ -430,7 +430,7 @@ describe("igniteTools (neutral, no dialect)", () => { } }); - it("binds runtime methods before calling execute and getView", async () => { + it("binds runtime.execute before storage and calls getView with runtime context", async () => { const component = new ThisBoundFakeComponent(); const { run } = igniteTools(component); const result = await run({ name: "setLimit", input: 7 }); diff --git a/packages/ignite-element/src/tests/types/igniteCore.types.test.ts b/packages/ignite-element/src/tests/types/igniteCore.types.test.ts index 82ca317e..03f385a1 100644 --- a/packages/ignite-element/src/tests/types/igniteCore.types.test.ts +++ b/packages/ignite-element/src/tests/types/igniteCore.types.test.ts @@ -5,7 +5,11 @@ import type { ActorWebReadModelSource as AdapterActorWebReadModelSource, ActorWebSource as AdapterActorWebSource, } from "@ignite-element/adapters/actor-web"; -import { command, commandMetadataSymbol } from "@ignite-element/core"; +import { + command, + commandMetadataSymbol, + type IgniteAdapter, +} from "@ignite-element/core"; import { makeAutoObservable } from "mobx"; import { describe, expect, expectTypeOf, it } from "vitest"; import { createMachine, type EventFrom, setup } from "xstate"; @@ -28,7 +32,6 @@ import * as actorWebPublic from "../../actor-web"; import { igniteCore as igniteCoreActorWebEntrypoint } from "../../actor-web"; import type { MobxEvent } from "../../adapters/MobxAdapter"; import type { XStateSnapshot } from "../../adapters/XStateAdapter"; -import counterStore, { counterSlice } from "../fixtures/reduxCounterStore"; import { igniteCore } from "../../IgniteCore"; import type { AdapterPack } from "../../IgniteElementFactory"; import type { @@ -73,6 +76,7 @@ import type { IgniteStoryTraceSnapshotEntry as ReduxIgniteStoryTraceSnapshotEntry, IgniteTestHelpers as ReduxIgniteTestHelpers, } from "../../redux"; +import { createAgentRuntime } from "../../runtime/agent"; import type { IgniteDomBridge, IgniteDomRoleExpectation, @@ -107,6 +111,7 @@ import { igniteCore as igniteCoreXState, test as xstateTest, } from "../../xstate"; +import counterStore, { counterSlice } from "../fixtures/reduxCounterStore"; type ActorWebShipmentContext = { shipmentId: string | null; @@ -196,6 +201,24 @@ const mobxCounterFactory = () => }); describe("igniteCore type inference", () => { + it("accepts an EventTarget host for the headless agent runtime", () => { + const adapter = {} as IgniteAdapter< + { count: number }, + { type: "INCREMENT" } + >; + const runtime = createAgentRuntime({ + eventTypes: [], + resolveRuntime: () => ({ + adapter, + additionalArgs: {}, + host: new EventTarget(), + }), + resolveView: () => ({}), + }); + + void runtime; + }); + it("re-exports IgniteCoreReturn from the xstate public entrypoint", () => { expectTypeOf< XStateIgniteCoreReturn< diff --git a/packages/ignite-element/src/tools/igniteTools.ts b/packages/ignite-element/src/tools/igniteTools.ts index f691d90c..b0fe5c95 100644 --- a/packages/ignite-element/src/tools/igniteTools.ts +++ b/packages/ignite-element/src/tools/igniteTools.ts @@ -85,8 +85,8 @@ export function igniteTools( const schema = runtime.getSchema(); const manifest = buildManifest(schema, canExecute); - // The model supplies dynamic command names, so treat `execute` as the loose - // runtime contract at this boundary. + // The model supplies dynamic command names, so bind the runtime method before + // storing it and treat `execute` as the loose contract at this boundary. const execute = runtime.execute.bind(runtime) as unknown as ( name: string, payload?: unknown, diff --git a/packages/ignite-element/src/tools/scalar.ts b/packages/ignite-element/src/tools/scalar.ts index 4be77dad..74e35375 100644 --- a/packages/ignite-element/src/tools/scalar.ts +++ b/packages/ignite-element/src/tools/scalar.ts @@ -36,6 +36,7 @@ export function toProviderInputSchema( type: "object", properties: { [SCALAR_KEY]: schema }, required: [SCALAR_KEY], + additionalProperties: false, }; } @@ -54,6 +55,7 @@ export function fromProviderInput( schema !== undefined && schema.type !== "object" && isPlainObject(input) && + Object.keys(input).length === 1 && SCALAR_KEY in input ) { return input[SCALAR_KEY];