eleven / messenger Public

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>
Paul Campbell b331a6e1ad1dcd801a964a021cb737b586ffa6d3 parent 1edc961
5 files changed, +76 −4
  • ios/LChatCore/Package.resolved +9 −0
  • test/flow.test.mjs +13 −0
  • web/static/apps.js +13 −0
  • web/static/apps/setup.js +35 −2
  • web/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 });
}