web: a parked setup question re-derives live when another client answers it (#1182)
The convergence fix (#1179) made the LOG converge, but a client's flow engine only derived its armed step at boot: Paul answered the name on the Mac and the web transcript streamed along live while its composer chip sat frozen on "Your name:" until a reload. Three small pieces: - apps.js foldInto gains an optional per-app post-fold hook (app.folded) plus hasAnswerWaiter, so a host-level script can react to a record landing from another client. - apps/setup.js uses it: a foreign setup.step folding in while THIS client sits parked on a question tears the run down and reboots the flow — every say/record dedups against the folded log, so the restart lands parked on the first genuinely-unanswered step. Own records are ledgered before emit so their socket echo never triggers a restart. - flow.js: env.reentry suppresses the "Welcome back" re-greeting on these mid-session re-derives — one per foreign answer would spam. Engine test covers the quiet reentry; full web suite green. The native mirror (Mac parked, web answers) is unverified — if the Mac composer shows the same staleness it gets the same treatment in a follow-up.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+76
−4
ios/LChatCore/Package.resolved+9 −0test/flow.test.mjs+13 −0web/static/apps.js+13 −0web/static/apps/setup.js+35 −2web/static/flow.js+6 −2
diff --git a/ios/LChatCore/Package.resolved b/ios/LChatCore/Package.resolved| index d82f91f..fe25d1c 100644 |
| --- a/ios/LChatCore/Package.resolved |
| +++ b/ios/LChatCore/Package.resolved |
| @@ -1,5 +1,14 @@ |
| { |
| "pins" : [ |
| + { |
| + "identity" : "sparkle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/sparkle-project/Sparkle", |
| + "state" : { |
| + "revision" : "b6496a74a087257ef5e6da1c5b29a447a60f5bd7", |
| + "version" : "2.9.4" |
| + } |
| + }, |
| { |
| "identity" : "swift-cmark", |
| "kind" : "remoteSourceControl", |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index 8baa06a..838b72a 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -256,6 +256,19 @@ await test("setup: a device that already knows the name RECORDS the skip so othe |
| assert.ok(h2.transcript.find((t) => t.extra.key === "q-group"), "converged on the group step"); |
| }); |
| +await test("setup: a reentry re-derive resumes quietly — no 'Welcome back' spam", async () => { |
| + // env.reentry marks a mid-session restart (the setup host reboots the flow |
| + // when ANOTHER client answers the parked step). It re-derives position from |
| + // the folded log like any resume, but must not re-greet: one "Welcome back" |
| + // per foreign answer would spam the feed. |
| + const h = harness({ myName: "Paul", done: { name: true, group: true }, envExtra: { reentry: true } }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.equal(h.transcript.some((t) => /^Welcome back/.test(t.text)), false, "no re-greeting"); |
| + // …and it still lands parked on the first unanswered step (email). |
| + assert.ok(h.transcript.find((t) => /How can people here reach you outside the chat/.test(t.text))); |
| +}); |
| + |
| await test("setup: resumed run — skips, Keep chip, skip-everything path", async () => { |
| // Resumed = a recorded step (done.name), not merely a set name — a name |
| // alone is the native creator's FIRST run and still gets the greeting. |
diff --git a/web/static/apps.js b/web/static/apps.js| index 3882e46..9965bf9 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -1061,6 +1061,14 @@ function foldInto(threadId, app, event, meta) { |
| const states = threadStore(threadId); |
| const prev = states.has(app.name) ? states.get(app.name) : undefined; |
| states.set(app.name, app.reduce(prev, event, meta || {})); |
| + // Post-fold hook (optional per app): lets a host-level script react to a |
| + // record landing from ANOTHER client — e.g. the setup flow re-deriving its |
| + // parked question when a second device answers it (apps/setup.js `folded`). |
| + // After the reduce, so state already reflects the event; a hook must never |
| + // break the fold. |
| + if (app.folded) { |
| + try { app.folded(threadId, event, meta || {}); } catch { /* hook-only failure */ } |
| + } |
| if (mid != null && app.snapshot !== false) { |
| maxFolded.set(threadId, Math.max(maxFolded.get(threadId) || 0, mid)); |
| dirty.add(threadId); |
| @@ -1262,6 +1270,11 @@ export function awaitAnswer(threadId, fn) { |
| export function cancelAnswer(threadId) { |
| answerWaiters.delete(threadId); |
| } |
| +// Whether a script sits parked on a question here — the setup flow's folded |
| +// hook uses it to tell "parked, needs a re-derive" from "mid-run or ended". |
| +export function hasAnswerWaiter(threadId) { |
| + return answerWaiters.has(threadId); |
| +} |
| // Called by the chat app after a message actually sent. Consumes the waiter — |
| // a follow-up question re-arms it — and returns whether one was listening. |
| // `payload` is the sent message ({ text, attachments }) so a script can also |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index b37d96a..3734ccd 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -10,7 +10,7 @@ |
| // the flow may name) and the io wiring the engine to eleven.js's feel (pacing, |
| // persistence) and shell.js's setup bridge (setSetupBridge). Like /tutorial it |
| // must work with no AI and no bot at all (a fresh instance has neither). |
| -import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; |
| +import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo, openAppThread } from "../apps.js"; |
| import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js"; |
| import { runFlow } from "../flow.js"; |
| import SETUP_FLOW from "../setup.flow.js"; |
| @@ -49,6 +49,25 @@ export const app = defineApp({ |
| if (e.type !== N.ev("step") || !e.label) return null; |
| return { text: e.label, action: true }; |
| }, |
| + // A step-record from ANOTHER client (a second device answering this same |
| + // conversation) landing while OUR engine sits parked on a question — the |
| + // 2026-07-19 stale-composer bug: the transcript streamed on live but the |
| + // armed "Your name:" chip never moved. Re-derive: tear the parked run down |
| + // and boot the flow again — every say/record dedups against the folded |
| + // log, so the restart lands parked on the first genuinely-unanswered step. |
| + // Own records are filtered by the ownRecords ledger (their socket echo is |
| + // not news); env.reentry suppresses the "Welcome back" re-greeting. |
| + folded(threadId, event) { |
| + if (event.type !== N.ev("step") || !event.step) return; |
| + const env = runningEnvs.get(threadId); |
| + if (!env || !hasAnswerWaiter(threadId)) return; // not parked — mid-run or ended |
| + const own = ownRecords.get(threadId); |
| + if (own && own.has(event.step)) return; |
| + cancelAnswer(threadId); |
| + running.delete(threadId); |
| + env.reentry = true; |
| + startSetup(env); |
| + }, |
| }); |
| // ---- the host binding ------------------------------------------------------- |
| @@ -56,6 +75,10 @@ export const app = defineApp({ |
| // One live run per thread (a reload starts a fresh one; the engine's step |
| // guards and say-dedupe handle everything within). |
| const running = new Set(); |
| +// The live run's env, kept so the folded hook can reboot the flow in place — |
| +// and the steps THIS client recorded, so its own echoes never trigger one. |
| +const runningEnvs = new Map(); // threadId → env |
| +const ownRecords = new Map(); // threadId → Set(step) |
| const say = makeSay("setup", N.ev("say")); |
| // The bridge-verb table: the CLOSED set of side effects the setup flow may |
| @@ -106,6 +129,7 @@ export function startSetup(env) { |
| const bridge = setupBridge(); |
| if (!bridge || running.has(env.threadId)) return; |
| running.add(env.threadId); |
| + runningEnvs.set(env.threadId, env); |
| env.sender = { name: "Eleven", kind: "bot" }; |
| env.bridge = { |
| myName: () => setupBridge().myName(), |
| @@ -118,6 +142,8 @@ export function startSetup(env) { |
| }; |
| env.onEnd = () => { |
| running.delete(env.threadId); |
| + runningEnvs.delete(env.threadId); |
| + ownRecords.delete(env.threadId); |
| if (env.live) env.live.flowState(false); // un-mute the real Eleven |
| }; |
| if (env.live) env.live.flowState(true); // the script has the floor (one Eleven) |
| @@ -126,7 +152,14 @@ export function startSetup(env) { |
| ask: awaitAnswer, |
| cancelAnswer, |
| verb, |
| - record: (step, label, value) => emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }), |
| + record: (step, label, value) => { |
| + // Ledger our own record BEFORE the emit — its socket echo folds back |
| + // through the `folded` hook and must read as ours, not a foreign answer. |
| + let own = ownRecords.get(env.threadId); |
| + if (!own) ownRecords.set(env.threadId, (own = new Set())); |
| + own.add(step); |
| + return emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" }); |
| + }, |
| done: (step) => { |
| const st = appStateFor(env.threadId, "setup"); |
| return !!(st && st.steps && st.steps[step] && st.steps[step].done); |
diff --git a/web/static/flow.js b/web/static/flow.js| index b9c6f99..11532d0 100644 |
| --- a/web/static/flow.js |
| +++ b/web/static/flow.js |
| @@ -147,9 +147,13 @@ export function runFlow(flow, env, io) { |
| // The resumed re-greeting (a client-only line — history already shows the |
| // real bubbles). Flows that special-case the return leg (welcome's passkey |
| - // ack) suppress it with resumeUnless. |
| + // ack) suppress it with resumeUnless. env.reentry marks a MID-SESSION |
| + // re-derive (another client answered the parked step — apps/setup.js's |
| + // folded hook), not a returning member: greeting them "Welcome back" once |
| + // per foreign answer would spam the feed. |
| env.resumed = evalCond(env, flow.resumedIf || false); |
| - if (env.resumed && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) { |
| + if (env.resumed && !env.reentry |
| + && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) { |
| io.say(env, flow.resumeSay, null, { local: true }); |
| } |