From 391ca272d39e5410ae2ddd3665c750277de33164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOS=CE=9E?= Date: Tue, 30 Jun 2026 16:04:39 -0400 Subject: [PATCH 1/3] fix: add smart-home async scene transition --- examples/agents/smart-home/src/home.ts | 103 ++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/examples/agents/smart-home/src/home.ts b/examples/agents/smart-home/src/home.ts index fd13dcca..7df99011 100644 --- a/examples/agents/smart-home/src/home.ts +++ b/examples/agents/smart-home/src/home.ts @@ -11,6 +11,7 @@ import { assign, setup } from "xstate"; export const ROOMS = ["living", "bedroom", "kitchen"] as const; export const DOORS = ["front", "back", "garage"] as const; export const SCENES = ["morning", "away", "movie", "night"] as const; +const SCENE_TRANSITION_DELAY_MS = 25; export type Room = (typeof ROOMS)[number]; export type Door = (typeof DOORS)[number]; @@ -22,6 +23,7 @@ export type HomeContext = { blinds: Record; // % open, 0–100 locks: Record; // true = locked activeScene: Scene | null; + pendingScene: Scene | null; }; type HomeEvent = @@ -29,13 +31,15 @@ type HomeEvent = | { type: "SET_THERMOSTAT"; room: Room; temp: number } | { type: "SET_BLINDS"; room: Room; percent: number } | { type: "SET_LOCK"; door: Door; locked: boolean } - | { type: "RUN_SCENE"; scene: Scene }; + | { type: "RUN_SCENE"; scene: Scene } + | { type: "START_SCENE_TRANSITION"; scene: Scene }; const initialContext: HomeContext = { lights: { living: false, bedroom: false, kitchen: false }, thermostat: { living: 68, bedroom: 68, kitchen: 68 }, blinds: { living: 0, bedroom: 0, kitchen: 0 }, activeScene: null, + pendingScene: null, locks: { front: true, back: true, garage: true }, }; @@ -141,6 +145,94 @@ const homeMachine = setup({ applyScene(context, event.scene), ), }, + START_SCENE_TRANSITION: { + target: "settlingScene", + actions: assign({ + pendingScene: ({ event }) => event.scene, + }), + }, + }, + }, + settlingScene: { + on: { + TOGGLE_LIGHT: { + actions: assign({ + lights: ({ context, event }) => ({ + ...context.lights, + [event.room]: event.on, + }), + activeScene: ({ context, event }) => + context.lights[event.room] === event.on + ? context.activeScene + : null, + }), + }, + SET_THERMOSTAT: { + actions: assign({ + thermostat: ({ context, event }) => ({ + ...context.thermostat, + [event.room]: event.temp, + }), + activeScene: ({ context, event }) => + context.thermostat[event.room] === event.temp + ? context.activeScene + : null, + }), + }, + SET_BLINDS: { + actions: assign({ + blinds: ({ context, event }) => ({ + ...context.blinds, + [event.room]: event.percent, + }), + activeScene: ({ context, event }) => + context.blinds[event.room] === event.percent + ? context.activeScene + : null, + }), + }, + SET_LOCK: { + actions: assign({ + locks: ({ context, event }) => ({ + ...context.locks, + [event.door]: event.locked, + }), + activeScene: ({ context, event }) => + context.locks[event.door] === event.locked + ? context.activeScene + : null, + }), + }, + RUN_SCENE: { + target: "active", + actions: assign(({ context, event }) => ({ + ...applyScene(context, event.scene), + pendingScene: null, + })), + }, + START_SCENE_TRANSITION: { + target: "settlingScene", + reenter: true, + actions: assign({ + pendingScene: ({ event }) => event.scene, + }), + }, + }, + after: { + [SCENE_TRANSITION_DELAY_MS]: { + target: "active", + actions: assign(({ context }) => { + const scene = context.pendingScene; + if (!scene) { + return { pendingScene: null }; + } + + return { + ...applyScene(context, scene), + pendingScene: null, + }; + }), + }, }, }, }, @@ -166,6 +258,7 @@ export function createHome() { blinds: { ...c.blinds }, locks: { ...c.locks }, activeScene: c.activeScene, + pendingScene: c.pendingScene, lightsOn: ROOMS.filter((room) => c.lights[room]), allDoorsLocked: DOORS.every((door) => c.locks[door]), }; @@ -226,6 +319,14 @@ export function createHome() { input: command.enum(SCENES), }, ), + transitionScene: command( + (scene: Scene) => actor.send({ type: "START_SCENE_TRANSITION", scene }), + { + description: + "Start a scene transition that acknowledges immediately and settles asynchronously.", + input: command.enum(SCENES), + }, + ), status: command( () => { // No-op: the home state is read from the returned snapshot / getView(). From f271b0c1ed27ec9fe2e512f7468f0cc482572192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOS=CE=9E?= Date: Tue, 30 Jun 2026 16:04:53 -0400 Subject: [PATCH 2/3] test: cover smart-home async observation settlement --- .fas-config.json | 2 +- .../agents/smart-home/src/agentLoop.test.ts | 137 +++++++++++++++- package.json | 2 +- scripts/__tests__/test-examples.test.mjs | 148 ++++++++++++++++++ scripts/test-examples.mjs | 98 +++++++++++- 5 files changed, 378 insertions(+), 9 deletions(-) diff --git a/.fas-config.json b/.fas-config.json index d35846da..24d57cfe 100644 --- a/.fas-config.json +++ b/.fas-config.json @@ -11,7 +11,7 @@ "formatCheckCommand": "npm run format:check", "formatFixCommand": "npm run format", "typecheckCommand": "npm run typecheck", - "testCommand": "pnpm --filter ignite-element test && pnpm run test:scripts && pnpm run test:examples", + "testCommand": "pnpm --filter ignite-element test && pnpm run test:scripts && pnpm run test:examples -- --require-covered-packages-match-discovered --covers-package examples/adapters/mobx --covers-package examples/adapters/redux --covers-package examples/adapters/xstate --covers-package examples/agents/smart-home --covers-package examples/apps/form-with-validation --covers-package examples/apps/spa-router", "replayCommand": "", "verifyScriptMode": "platform", "screenshotArtifactDir": ".fas/artifacts/screenshots", diff --git a/examples/agents/smart-home/src/agentLoop.test.ts b/examples/agents/smart-home/src/agentLoop.test.ts index f467f401..566173bb 100644 --- a/examples/agents/smart-home/src/agentLoop.test.ts +++ b/examples/agents/smart-home/src/agentLoop.test.ts @@ -6,12 +6,12 @@ // AND a stress test of the agent API surface — varied command input schemas, // the Option D scalar round-trip, the event observation stream, and errors-as- // values — encoded as always-on assertions. -import { igniteTools } from "ignite-element/tools"; +import { igniteTools, isOk } from "ignite-element/tools"; import { type AnthropicResponse, anthropic, } from "ignite-element/tools/anthropic"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runHomeAgent } from "./agentLoop"; import { createHome, DOORS, ROOMS, SCENES } from "./home"; import { type Model, scriptedModel } from "./model"; @@ -216,6 +216,139 @@ describe("smart-home agent — scripted session (round-trip, headless)", () => { expect(home.getView().activeScene).toBe("morning"); }); + it("observes a delayed scene after run() acknowledges the pending view", async () => { + vi.useFakeTimers(); + const home = createHome(); + const tools = igniteTools(home); + const observations: unknown[] = []; + const subscription = tools.observe((observation) => { + observations.push(observation); + }); + + try { + const result = await tools.run({ + name: "transitionScene", + input: "morning", + }); + + expect(isOk(result)).toBe(true); + if (!isOk(result)) { + throw new Error( + `Expected transitionScene to run: ${result.error.kind}`, + ); + } + + expect(result.value.events.map((event) => event.type)).not.toContain( + "scene-applied", + ); + expect(result.value.view).toMatchObject({ + activeScene: null, + pendingScene: "morning", + lights: { living: false, bedroom: false, kitchen: false }, + }); + + const interimResult = await tools.run({ + name: "setThermostat", + input: { room: "living", temp: 69 }, + }); + + expect(isOk(interimResult)).toBe(true); + if (!isOk(interimResult)) { + throw new Error( + `Expected setThermostat to run: ${interimResult.error.kind}`, + ); + } + + expect(interimResult.value.view).toMatchObject({ + activeScene: null, + pendingScene: "morning", + thermostat: { living: 69 }, + }); + + await vi.runOnlyPendingTimersAsync(); + + expect(home.getView()).toMatchObject({ + activeScene: "morning", + pendingScene: null, + lights: { living: true, bedroom: true, kitchen: true }, + thermostat: { living: 70 }, + }); + expect(observations).toEqual( + expect.arrayContaining([ + { + type: "event", + event: { type: "scene-applied", payload: { scene: "morning" } }, + }, + expect.objectContaining({ + type: "view", + view: expect.objectContaining({ + activeScene: "morning", + pendingScene: null, + lights: expect.objectContaining({ + living: true, + bedroom: true, + kitchen: true, + }), + }), + }), + ]), + ); + } finally { + subscription.unsubscribe(); + vi.useRealTimers(); + } + }); + + it("restarts a delayed scene when transitionScene is repeated", async () => { + vi.useFakeTimers(); + const home = createHome(); + const tools = igniteTools(home); + + try { + const firstResult = await tools.run({ + name: "transitionScene", + input: "morning", + }); + expect(isOk(firstResult)).toBe(true); + + await vi.advanceTimersByTimeAsync(10); + + const secondResult = await tools.run({ + name: "transitionScene", + input: "movie", + }); + + expect(isOk(secondResult)).toBe(true); + if (!isOk(secondResult)) { + throw new Error( + `Expected transitionScene to run: ${secondResult.error.kind}`, + ); + } + + expect(secondResult.value.view).toMatchObject({ + activeScene: null, + pendingScene: "movie", + }); + + await vi.advanceTimersByTimeAsync(20); + + expect(home.getView()).toMatchObject({ + activeScene: null, + pendingScene: "movie", + }); + + await vi.advanceTimersByTimeAsync(5); + + expect(home.getView()).toMatchObject({ + activeScene: "movie", + pendingScene: null, + lights: { living: false }, + }); + } finally { + vi.useRealTimers(); + } + }); + it("returns defensive copies from the derived view", () => { const home = createHome(); const view = home.getView(); diff --git a/package.json b/package.json index ebe2544f..02b77137 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test:packages": "pnpm --filter ignite-element test", "test:scripts": "node --test scripts/__tests__/*.test.mjs", "test:examples": "node scripts/test-examples.mjs", - "test:full": "pnpm run test:packages && pnpm run test:scripts && pnpm run test:examples", + "test:full": "pnpm run test:packages && pnpm run test:scripts && pnpm run test:examples -- --require-covered-packages-match-discovered --covers-package examples/adapters/mobx --covers-package examples/adapters/redux --covers-package examples/adapters/xstate --covers-package examples/agents/smart-home --covers-package examples/apps/form-with-validation --covers-package examples/apps/spa-router", "test:coverage": "pnpm --filter ignite-element test:coverage", "test:node": "pnpm --filter ignite-element test:node", "typecheck": "pnpm -r run typecheck", diff --git a/scripts/__tests__/test-examples.test.mjs b/scripts/__tests__/test-examples.test.mjs index 93315cbd..107fda6c 100644 --- a/scripts/__tests__/test-examples.test.mjs +++ b/scripts/__tests__/test-examples.test.mjs @@ -13,6 +13,10 @@ const expectedExampleRoots = [ "examples/apps/form-with-validation", "examples/apps/spa-router", ]; +const expectedCoverageArgs = expectedExampleRoots.flatMap((exampleRoot) => [ + "--covers-package", + exampleRoot, +]); describe("test-examples", () => { it("discovers example roots with runtime tests", () => { @@ -27,6 +31,35 @@ describe("test-examples", () => { assert.deepEqual(output.trim().split("\n"), expectedExampleRoots); }); + it("supports equals syntax for the examples root", () => { + const output = execFileSync( + "node", + ["scripts/test-examples.mjs", "--list", "--examples-root=examples"], + { + encoding: "utf8", + }, + ); + + assert.deepEqual(output.trim().split("\n"), expectedExampleRoots); + }); + + it("validates covered example packages are discovered", () => { + const output = execFileSync( + "node", + [ + "scripts/test-examples.mjs", + "--list", + "--require-covered-packages-match-discovered", + ...expectedCoverageArgs, + ], + { + encoding: "utf8", + }, + ); + + assert.deepEqual(output.trim().split("\n"), expectedExampleRoots); + }); + it("fails when a runtime test is outside an example package", () => { const examplesRoot = mkdtempSync(path.join(tmpdir(), "ignite-examples-")); const orphanDir = path.join(examplesRoot, "orphan", "src"); @@ -58,4 +91,119 @@ describe("test-examples", () => { rmSync(examplesRoot, { force: true, recursive: true }); } }); + + it("rejects path-valued flags without a path", () => { + assert.throws( + () => + execFileSync( + "node", + ["scripts/test-examples.mjs", "--examples-root", "--list"], + { + encoding: "utf8", + stderr: "pipe", + }, + ), + (error) => { + assert.equal(error.status, 1); + assert.match(String(error.stderr), /--examples-root requires a path\./); + return true; + }, + ); + + assert.throws( + () => + execFileSync( + "node", + ["scripts/test-examples.mjs", "--list", "--covers-package", "--list"], + { + encoding: "utf8", + stderr: "pipe", + }, + ), + (error) => { + assert.equal(error.status, 1); + assert.match( + String(error.stderr), + /--covers-package requires a path\./, + ); + return true; + }, + ); + }); + + it("fails when a covered example package has no runtime tests", () => { + const examplesRoot = mkdtempSync(path.join(tmpdir(), "ignite-examples-")); + const testedExampleDir = path.join(examplesRoot, "tested", "src"); + const uncoveredExampleRoot = path.join(examplesRoot, "uncovered"); + + mkdirSync(testedExampleDir, { recursive: true }); + mkdirSync(uncoveredExampleRoot, { recursive: true }); + writeFileSync( + path.join(examplesRoot, "tested", "package.json"), + '{"name":"tested-example"}', + ); + writeFileSync(path.join(testedExampleDir, "tested.test.ts"), ""); + writeFileSync( + path.join(uncoveredExampleRoot, "package.json"), + '{"name":"uncovered-example"}', + ); + + try { + assert.throws( + () => + execFileSync( + "node", + [ + "scripts/test-examples.mjs", + "--examples-root", + examplesRoot, + "--list", + "--covers-package", + uncoveredExampleRoot, + ], + { + encoding: "utf8", + stderr: "pipe", + }, + ), + (error) => { + assert.equal(error.status, 1); + assert.match( + String(error.stderr), + /Covered example package was not discovered with runtime tests: /, + ); + return true; + }, + ); + } finally { + rmSync(examplesRoot, { force: true, recursive: true }); + } + }); + + it("fails when exact covered packages drift from discovered examples", () => { + assert.throws( + () => + execFileSync( + "node", + [ + "scripts/test-examples.mjs", + "--list", + "--require-covered-packages-match-discovered", + ...expectedCoverageArgs.slice(0, -2), + ], + { + encoding: "utf8", + stderr: "pipe", + }, + ), + (error) => { + assert.equal(error.status, 1); + assert.match( + String(error.stderr), + /Covered example package list is missing discovered runtime tests: /, + ); + return true; + }, + ); + }); }); diff --git a/scripts/test-examples.mjs b/scripts/test-examples.mjs index d9c91452..1befd484 100644 --- a/scripts/test-examples.mjs +++ b/scripts/test-examples.mjs @@ -30,17 +30,70 @@ const configNames = [ const rawArgs = process.argv.slice(2); const args = new Set(rawArgs); -const examplesRootArgIndex = rawArgs.indexOf("--examples-root"); + +function isMissingPathValue(value) { + return !value || value.startsWith("--"); +} + +const examplesRootAssignmentPrefix = "--examples-root="; +const examplesRootArgIndex = rawArgs.findIndex( + (arg) => + arg === "--examples-root" || arg.startsWith(examplesRootAssignmentPrefix), +); +let examplesRootArg; + +if (examplesRootArgIndex !== -1) { + const matchedArg = rawArgs[examplesRootArgIndex]; + examplesRootArg = matchedArg.startsWith(examplesRootAssignmentPrefix) + ? matchedArg.slice(examplesRootAssignmentPrefix.length) + : rawArgs[examplesRootArgIndex + 1]; + + if (isMissingPathValue(examplesRootArg)) { + console.error("--examples-root requires a path."); + process.exit(1); + } +} + const examplesRoot = examplesRootArgIndex === -1 ? path.join(repoRoot, "examples") - : path.resolve(repoRoot, rawArgs[examplesRootArgIndex + 1] ?? ""); + : path.resolve(repoRoot, examplesRootArg); -if (examplesRootArgIndex !== -1 && !rawArgs[examplesRootArgIndex + 1]) { - console.error("--examples-root requires a path."); - process.exit(1); +function readRepeatedPathFlag(name) { + const values = []; + const assignmentPrefix = `${name}=`; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === name) { + const value = rawArgs[index + 1]; + if (isMissingPathValue(value)) { + console.error(`${name} requires a path.`); + process.exit(1); + } + values.push(path.resolve(repoRoot, value)); + index += 1; + continue; + } + + if (arg.startsWith(assignmentPrefix)) { + const value = arg.slice(assignmentPrefix.length); + if (isMissingPathValue(value)) { + console.error(`${name} requires a path.`); + process.exit(1); + } + values.push(path.resolve(repoRoot, value)); + } + } + + return values; } +const coveredPackageRoots = readRepeatedPathFlag("--covers-package"); +const requireCoveredPackagesMatchDiscovered = args.has( + "--require-covered-packages-match-discovered", +); + async function findTestFiles(dir) { const entries = await readdir(dir, { withFileTypes: true }); const files = []; @@ -141,6 +194,41 @@ if (exampleRoots.length === 0) { process.exit(1); } +const missingCoveredPackageRoots = coveredPackageRoots.filter( + (coveredPackageRoot) => !exampleRoots.includes(coveredPackageRoot), +); + +if (missingCoveredPackageRoots.length > 0) { + for (const coveredPackageRoot of missingCoveredPackageRoots) { + console.error( + `Covered example package was not discovered with runtime tests: ${path.relative( + repoRoot, + coveredPackageRoot, + )}`, + ); + } + process.exit(1); +} + +if (requireCoveredPackagesMatchDiscovered) { + const coveredPackageRootSet = new Set(coveredPackageRoots); + const missingCoveredDiscoveredRoots = exampleRoots.filter( + (exampleRoot) => !coveredPackageRootSet.has(exampleRoot), + ); + + if (missingCoveredDiscoveredRoots.length > 0) { + for (const exampleRoot of missingCoveredDiscoveredRoots) { + console.error( + `Covered example package list is missing discovered runtime tests: ${path.relative( + repoRoot, + exampleRoot, + )}`, + ); + } + process.exit(1); + } +} + if (args.has("--list")) { for (const exampleRoot of exampleRoots) { console.log(path.relative(repoRoot, exampleRoot)); From a832a7235253d105bf7de76b2f749bc584e0bce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?JOS=CE=9E?= Date: Tue, 30 Jun 2026 16:05:03 -0400 Subject: [PATCH 3/3] chore: close smart-home async observation gap --- .fas/TASKS.md | 11 ++++ ...-by-adding-focused-async-long-running-e.md | 66 +++++++++++++++++++ examples/agents/smart-home/GAPS.md | 21 +++--- 3 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 .fas/tasks/fix-smart-home-gaps-4-by-adding-focused-async-long-running-e.md diff --git a/.fas/TASKS.md b/.fas/TASKS.md index f2a3c1a1..1630561f 100644 --- a/.fas/TASKS.md +++ b/.fas/TASKS.md @@ -1660,6 +1660,17 @@ No active tasks. - Policy sensitivity: standard - Blast radius: cross-cutting +### Task: Fix smart-home GAPS #4 by adding focused async/long-running effect coverage for act+ack versus observe-stream settlement before Phase C + +- Title: Fix smart-home GAPS #4 by adding focused async/long-running effect coverage for act+ack versus observe-stream settlement before Phase C +- Mode: single-agent +- Status: review +- Owner: reviewer +- Brief: .fas/tasks/fix-smart-home-gaps-4-by-adding-focused-async-long-running-e.md +- Verification lane: fast +- Policy sensitivity: standard +- Blast radius: cross-cutting + ## Template ### Task: diff --git a/.fas/tasks/fix-smart-home-gaps-4-by-adding-focused-async-long-running-e.md b/.fas/tasks/fix-smart-home-gaps-4-by-adding-focused-async-long-running-e.md new file mode 100644 index 00000000..fb0d5b34 --- /dev/null +++ b/.fas/tasks/fix-smart-home-gaps-4-by-adding-focused-async-long-running-e.md @@ -0,0 +1,66 @@ +# Fix smart-home GAPS #4 by adding focused async/long-running effect coverage for act+ack versus observe-stream settlement + +## Source +Created with `fas create-task` on 2026-06-30. + +## Problem +The smart-home dogfood currently proves synchronous command acknowledgement only. GAPS #4 calls out the missing contract case: a command acknowledges before a longer-running scene transition settles, and the later state/event must arrive through the observe stream rather than being folded into `run()`'s acknowledgement result. Add a focused pre-Phase-C example/test for that behavior without building the full terminal-to-browser bridge. + +## Acceptance criteria +- The work is tracked in `.fas/TASKS.md`. +- The task has a clear implementation and verification plan before execution starts. +- The smart-home example has an async scene transition command that returns an acknowledgement view before the scene is fully applied. +- `igniteTools(...).observe(...)` observes the later view/event settlement for that transition. +- The focused smart-home runtime test fails before the implementation and passes after it. +- `examples/agents/smart-home/GAPS.md` marks gap #4 fixed and leaves Phase C responsible for cross-runtime bridge gaps only. + +## Proposed solution +- Add an XState delayed transition to the smart-home machine for a dedicated async scene command. Keep `run()` act+ack semantics unchanged: the returned observation captures the pending scene at acknowledgement. Use `igniteTools.observe()` in the test to assert the eventual settled view and `scene-applied` event after the delayed transition. + +## Alternatives considered +- Build the full Phase C terminal-to-browser bridge now: rejected for this task because the user asked to clear the remaining GAPS first, and #4 can be proven with a smaller smart-home example test. +- Add a bounded `settle` option to `execute()` now: rejected because #4 is a coverage/contract gap; no API need has been proven yet. + +## Affected files +- examples/agents/smart-home/src/home.ts +- examples/agents/smart-home/src/agentLoop.test.ts +- examples/agents/smart-home/GAPS.md +- scripts/test-examples.mjs +- scripts/__tests__/test-examples.test.mjs +- package.json +- .fas-config.json + +## Scope Amendments +- Scope is intentionally limited to the smart-home example and its gap tracker; no runtime API or provider dialect changes are planned. +- Validation exposed that FAS could not recognize the top-level examples lane as covering non-workspace example package tests. This task also updates the existing example runtime-test lane with explicit covered-package assertions and points FAS at `test:full`, without making examples workspace members. + +## Implementation plan +- First add the failing smart-home test for act+ack versus observe-stream settlement. +- Add the minimal async scene transition command and view field needed to make the test pass. +- Update `GAPS.md` to mark #4 fixed and describe what remains deferred to Phase C. +- Add a small example-runner coverage marker so the FAS package-test gate recognizes that full verification covers changed non-workspace example tests. + +## Verification plan +- Run the focused smart-home example test. +- Run the script test covering the example runtime-test lane marker. +- Run the full example runtime-test lane. +- Run `fas validate-task` for the inner-loop verification gate. +- Run `.fas/scripts/verify.sh --full` at the final release-quality gate when tracked files change. + +## Risks +- XState delayed transitions use timers; keep the delay short and use Vitest fake timers in the focused test to avoid flakiness. +- Do not change existing synchronous `runScene` behavior; add the async command separately so existing dogfood expectations stay stable. + +## Dependencies +- None known at task creation. + +## Open questions +- None captured at task creation. + +## Artifact links +- Planning: `.fas/state/planning.json` +- Task packet: `.fas/state/task-packet.json` +- Commit plan: `.fas/state/commit-plan.json` +- Verification: `.fas/state/verification/latest.json` +- Review: `.fas/state/boundary-review-findings.md` +- Workflow: `.fas/state/workflows/` diff --git a/examples/agents/smart-home/GAPS.md b/examples/agents/smart-home/GAPS.md index 8ae81424..d52c16e5 100644 --- a/examples/agents/smart-home/GAPS.md +++ b/examples/agents/smart-home/GAPS.md @@ -41,14 +41,19 @@ schema-declared events and derived view transitions with a standard `unsubscribe()` handle, so the agent loop can stay on one act → observe → act surface instead of calling runtime `on()` / `watchView()` directly. -## 4. Async / long-running effects (act+ack vs settle) are untested here - -Scenes in this example apply **synchronously**, so `run()`'s acknowledgement -snapshot already reflects the full effect. The interesting contract case — `run()` -returns at acknowledgement while the effect settles over time — needs a genuinely -async scene (real-time transition) or a remote actor. Phase C (terminal↔browser -over a transport) is the natural place to exercise it; it will show whether a -bounded `settle` opt-in on `execute()` is warranted (currently deferred). +## 4. ✅ FIXED — async / long-running effects are observed after act+ack + +**Was:** scenes in this example applied **synchronously**, so `run()`'s +acknowledgement snapshot already reflected the full effect. The interesting +contract case — `run()` returns at acknowledgement while the effect settles over +time — was untested here. + +**Fixed in this PR:** the smart-home now has a delayed `transitionScene` command +that acknowledges immediately with `pendingScene` in the view, then settles via +the runtime observation stream. The focused test proves `run()` keeps act+ack +semantics while `igniteTools(...).observe(...)` receives the later settled view +and `scene-applied` event. Phase C still owns the broader terminal↔browser +transport and cross-runtime bridge gaps. ## 5. Scalar `value`-wrapping costs LLM legibility (known Option D trade-off)