@haikit/server 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts CHANGED
@@ -10,12 +10,17 @@ import {
10
10
  type ModelAdapter,
11
11
  type Notify,
12
12
  type Progress,
13
+ type SendSignal,
14
+ type Signal,
13
15
  type StoreAdapter,
14
16
  type Tool,
15
17
  type ToolCtx,
16
18
  type ToolReturn,
19
+ type Update,
20
+ type UpdateOptions,
17
21
  type WireEvent,
18
22
  } from "@haikit/core";
23
+ import { Broadcast } from "./signals.js";
19
24
 
20
25
  export interface HaiConfig {
21
26
  model: ModelAdapter;
@@ -65,6 +70,27 @@ export interface HaiConfig {
65
70
  * browser still shows it at once.
66
71
  */
67
72
  maxWakes?: { count: number; perMs: number };
73
+ /**
74
+ * Set when app code revises surfaces with `hai.update`, which refuses
75
+ * without it. The browser then opens the events stream, which is how a
76
+ * revision reaches it, and the events route starts the turns an update sent
77
+ * with `wake` asks for.
78
+ */
79
+ updates?: boolean;
80
+ /**
81
+ * The signals this app sends with `hai.signal`: short-lived broadcasts to
82
+ * every open browser. A signal not listed here is refused. With any listed,
83
+ * the browser opens the events stream, which is how a signal reaches it.
84
+ */
85
+ signals?: Signal<any>[];
86
+ /**
87
+ * How presence is counted (`hai.onPresence`). `graceMs`: how long a
88
+ * conversation whose events stream closed still counts, so a browser that
89
+ * reconnects doesn't flicker the count; default 5 s. `heartbeatMs`: how
90
+ * often servers sharing a store tell each other their counts; a server not
91
+ * heard from for three beats stops counting; default 10 s.
92
+ */
93
+ presence?: { graceMs?: number; heartbeatMs?: number };
68
94
  }
69
95
 
70
96
  /**
@@ -111,9 +137,40 @@ interface Hop {
111
137
  * so two renders at once cannot both ask; the handle is known once it shows,
112
138
  * and `settled` resolves when its render has shown it or failed.
113
139
  */
114
- asking: { handle: string | null; settled: Promise<void> } | null;
140
+ asking: {
141
+ handle: string | null;
142
+ settled: Promise<void>;
143
+ /** The digest it was asked under, and its latest, once a later call in the reply has revised it. */
144
+ question?: string;
145
+ digest?: string;
146
+ } | null;
147
+ }
148
+
149
+ /** A `hai.update` write, and every call it answers. */
150
+ interface UpdateJob {
151
+ props: unknown;
152
+ model: string | undefined;
153
+ wake: boolean;
154
+ timeoutMs: number;
155
+ waiters: { resolve: (written: { handle: string }) => void; reject: (err: unknown) => void }[];
156
+ }
157
+
158
+ /** The `hai.update` calls for one surface: the next to write, once the one writing is done. */
159
+ interface UpdateQueue {
160
+ next: UpdateJob | null;
115
161
  }
116
162
 
163
+ /** The longest delay a Node timer keeps; past it, one fires after about a millisecond. */
164
+ const MAX_TIMER_MS = 2_147_483_647;
165
+
166
+ /** How long `hai.update` waits for a turn to let the conversation go, by default. */
167
+ const UPDATE_TIMEOUT_MS = 30_000;
168
+ /** How many conversations `hai.update` remembers its surfaces' handles for. */
169
+ const UPDATE_NAMES_KEPT = 10_000;
170
+ /** The first wait before `hai.update` tries a busy conversation again, and the longest. */
171
+ const UPDATE_RETRY_MS = 100;
172
+ const UPDATE_RETRY_MAX_MS = 2_000;
173
+
117
174
  /** An elicit surface a tool call showed, and the digest its answer goes out under. */
118
175
  interface Question {
119
176
  handle: string;
@@ -130,6 +187,18 @@ export class Hai {
130
187
  private readonly queryUi: Tool;
131
188
  /** Handles each conversation has a revision of in flight, so two calls can't revise one at once. */
132
189
  private readonly revising = new WeakMap<Conversation, Set<string>>();
190
+ private readonly signals = new Map<string, Signal<any>>();
191
+ /** Signals sent, streams open, and presence, here and on the other servers. */
192
+ private readonly broadcast: Broadcast;
193
+ /** `hai.update` calls by conversation and surface: one writing, and the newest waiting behind it. */
194
+ private readonly updating = new Map<string, UpdateQueue>();
195
+ /**
196
+ * The surfaces `hai.update` has revised, by conversation: each one's first
197
+ * handle and its latest, both mapped to the first, so calls naming either
198
+ * share a queue. Kept for the most recent conversations only; one let go of
199
+ * costs coalescing, never correctness, since every write revises the latest.
200
+ */
201
+ private readonly surfaceNames = new Map<string, Map<string, string>>();
133
202
  /** What the model gets as its system prompt: the app's, then the scope. */
134
203
  readonly system: string;
135
204
  private seq = 0;
@@ -157,6 +226,25 @@ export class Hai {
157
226
  );
158
227
  }
159
228
  this.maxWakes = { count, perMs };
229
+ for (const s of config.signals ?? []) {
230
+ if (this.signals.has(s.name)) throw new Error(`two signals are named "${s.name}"`);
231
+ this.signals.set(s.name, s);
232
+ }
233
+ const { graceMs = 5_000, heartbeatMs = 10_000 } = config.presence ?? {};
234
+ for (const [name, ms] of [["graceMs", graceMs], ["heartbeatMs", heartbeatMs]] as const) {
235
+ // Past the longest timer Node keeps, it fires after about a millisecond:
236
+ // a grace would end at once, and a heartbeat would spin.
237
+ // Whole milliseconds, as timers count them: a fractional one would be
238
+ // scheduled shorter than the staleness arithmetic assumes.
239
+ if (typeof ms !== "number" || !Number.isInteger(ms) || ms <= 0 || ms > MAX_TIMER_MS) {
240
+ throw new RangeError(
241
+ `presence.${name} must be a whole number of milliseconds, from 1 to ${MAX_TIMER_MS} (got ${String(ms)})`,
242
+ );
243
+ }
244
+ }
245
+ this.broadcast = new Broadcast(config.store, graceMs, heartbeatMs);
246
+ // hear the other servers' signals from now, so a stream that opens later gets their latest
247
+ if (this.signals.size) this.broadcast.start();
160
248
  // query_ui is DERIVED, never authored. It cannot drift from the surfaces
161
249
  // that actually exist, and it always exists.
162
250
  const queryUi = this.buildQueryUiTool();
@@ -171,14 +259,29 @@ export class Hai {
171
259
  }
172
260
  }
173
261
 
174
- /** Whether this app sends notices, so the browser should open the events stream. */
262
+ /**
263
+ * Whether this app sends notices, `hai.update`'s included: what the
264
+ * conversation's history may have unread, and the events stream may have to
265
+ * deliver.
266
+ */
175
267
  get sendsNotices(): boolean {
176
- return this.notices.size > 0;
268
+ return this.notices.size > 0 || this.config.updates === true;
177
269
  }
178
270
 
179
- /** Whether any of its notices are `wake` notices, which the events route starts turns for. */
271
+ /**
272
+ * Whether the browser should open the events stream: for notices, or for
273
+ * signals, which travel on it too.
274
+ */
275
+ get streamsEvents(): boolean {
276
+ return this.sendsNotices || this.signals.size > 0;
277
+ }
278
+
279
+ /**
280
+ * Whether any of its notices may be `wake` notices, which the events route
281
+ * starts turns for: a registered one, or an update sent with `wake`.
282
+ */
180
283
  get wakes(): boolean {
181
- return [...this.notices.values()].some((n) => n.notice.kind === "wake");
284
+ return this.config.updates === true || [...this.notices.values()].some((n) => n.notice.kind === "wake");
182
285
  }
183
286
 
184
287
  /**
@@ -225,6 +328,282 @@ export class Hai {
225
328
  return { seq: record.seq };
226
329
  };
227
330
 
331
+ /**
332
+ * Send a signal to every open events stream, on this server and, through a
333
+ * store with `publish` and `subscribe`, on the others. Not stored, not
334
+ * numbered, never in the model's context or the transcript: the browser
335
+ * hands it to the app's handler for it. Each signal's latest payload is
336
+ * kept, and a stream that opens later is sent it first.
337
+ *
338
+ * Throws for a signal not listed in `signals`, or a payload that fails its
339
+ * schema.
340
+ */
341
+ readonly signal: SendSignal = (signal, payload) => {
342
+ const name = signal?.name;
343
+ if (this.signals.get(name) !== signal) {
344
+ throw new Error(`signal "${name}" is not registered: list it in createHai({ signals })`);
345
+ }
346
+ this.broadcast.send({ type: "signal", name, version: signal.version, payload: signal.payload.parse(payload) });
347
+ };
348
+
349
+ /**
350
+ * How many conversations have an events stream open, on every server
351
+ * sharing the store: a tab with the app open, as near as the server can
352
+ * tell. `listener` hears the count now and whenever it changes. A stream
353
+ * that closes still counts for `presence.graceMs`, so a reconnect doesn't
354
+ * flicker it. Returns a function that stops listening.
355
+ *
356
+ * Each server counts its own and the totals are summed, so a conversation
357
+ * counts once per server it has a stream on: one that reconnects to another
358
+ * server counts on both until the grace runs out, and one with streams on
359
+ * two servers at once counts twice. (The client opens one stream per
360
+ * conversation, so the second doesn't happen with it.)
361
+ *
362
+ * Only the number leaves a server: no conversation ids, and nothing the
363
+ * model sees. An app with no notices, updates or signals opens no events
364
+ * stream, so counts nothing.
365
+ */
366
+ onPresence(listener: (count: number) => void): () => void {
367
+ return this.broadcast.onPresence(listener);
368
+ }
369
+
370
+ /**
371
+ * An events stream opened for `conversationId`: it hears every signal from
372
+ * now on, starting with each one's latest payload, and counts toward
373
+ * presence. The events route calls this; call it too if you serve the
374
+ * stream yourself, and call what it returns when the stream closes.
375
+ */
376
+ streamOpened(conversationId: string, emit: Emit): () => void {
377
+ return this.broadcast.opened(conversationId, emit);
378
+ }
379
+
380
+ /**
381
+ * Revise a surface from outside any turn: a webhook, a job, a price feed.
382
+ * The revision is stored and attached as `ctx.update` does, under the
383
+ * conversation's lease, so a click can never act on props the server doesn't
384
+ * hold. Then an *update notice* tells both sides: the events stream sends
385
+ * the browser the revision, which it swaps into the component on screen,
386
+ * and the model hears which handle replaced which, with the new digest, in
387
+ * the next user message (or at once, with `wake`).
388
+ *
389
+ * `handle` may be any handle the surface has had: the revision replaces the
390
+ * latest. It throws if that one was answered, if the conversation is out of
391
+ * date, and, after waiting `timeoutMs` for a turn to let the conversation
392
+ * go, with `ConversationBusy`. A question waiting on the surface waits on
393
+ * the revision.
394
+ *
395
+ * One write per surface is in flight at a time. A call made meanwhile
396
+ * replaces any still waiting behind it, and every call it replaced resolves
397
+ * to the handle it writes; a wake any of them asked for still wakes.
398
+ */
399
+ readonly update: Update = (conversationId, surface, handle, props, options = {}) => {
400
+ const impl = surface as AnySurfaceImpl;
401
+ const name = impl?.surface?.name;
402
+ if (this.surfaces.get(name) !== impl) {
403
+ return Promise.reject(new Error(`surface "${name}" is not registered: list it in createHai({ surfaces })`));
404
+ }
405
+ // Required even when notices open the events stream: it is also what has
406
+ // the events route start the turn an update sent with `wake` asks for.
407
+ if (this.config.updates !== true) {
408
+ return Promise.reject(new Error("hai.update is off: set createHai({ updates: true })"));
409
+ }
410
+ const { timeoutMs = UPDATE_TIMEOUT_MS, model, wake } = options as UpdateOptions;
411
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs < 0) {
412
+ return Promise.reject(new RangeError(`timeoutMs must be a finite number of milliseconds, zero or more (got ${String(timeoutMs)})`));
413
+ }
414
+ if (model !== undefined && typeof model !== "string") return Promise.reject(new TypeError("model must be a string"));
415
+ let parsed: unknown;
416
+ try {
417
+ // Validated before anything waits: props may come from outside this process.
418
+ parsed = impl.surface.props.parse(props);
419
+ } catch (err) {
420
+ return Promise.reject(err);
421
+ }
422
+
423
+ return new Promise((resolve, reject) => {
424
+ // the surface's first handle, when it is one `hai.update` has revised here
425
+ const first = this.surfaceNames.get(conversationId)?.get(handle) ?? handle;
426
+ const key = `${conversationId}\u0000${first}`;
427
+ const waiter = { resolve, reject };
428
+ const queue = this.updating.get(key);
429
+ if (queue) {
430
+ const behind = queue.next;
431
+ queue.next = {
432
+ props: parsed,
433
+ model,
434
+ wake: wake === true || behind?.wake === true,
435
+ timeoutMs,
436
+ waiters: [...(behind?.waiters ?? []), waiter],
437
+ };
438
+ return;
439
+ }
440
+ const fresh: UpdateQueue = { next: { props: parsed, model, wake: wake === true, timeoutMs, waiters: [waiter] } };
441
+ this.updating.set(key, fresh);
442
+ void (async () => {
443
+ for (let job = fresh.next; job; job = fresh.next) {
444
+ fresh.next = null;
445
+ try {
446
+ const { replaced, ...written } = await this.revise(conversationId, impl, handle, job);
447
+ this.name(conversationId, first, replaced, written.handle);
448
+ for (const w of job.waiters) w.resolve(written);
449
+ } catch (err) {
450
+ for (const w of job.waiters) w.reject(err);
451
+ }
452
+ }
453
+ this.updating.delete(key);
454
+ })();
455
+ });
456
+ };
457
+
458
+ /** Record that `first`'s surface went from `replaced` to `latest`, for the next call's queue. */
459
+ private name(conversationId: string, first: string, replaced: string, latest: string) {
460
+ const names = this.surfaceNames.get(conversationId) ?? new Map<string, string>();
461
+ // only the first and the latest are kept: what a caller is likely to hold
462
+ if (replaced !== first) names.delete(replaced);
463
+ names.set(first, first).set(latest, first);
464
+ // most recently revised last, so the oldest conversation goes first
465
+ this.surfaceNames.delete(conversationId);
466
+ this.surfaceNames.set(conversationId, names);
467
+ if (this.surfaceNames.size > UPDATE_NAMES_KEPT) this.surfaceNames.delete(this.surfaceNames.keys().next().value!);
468
+ }
469
+
470
+ /**
471
+ * Append the update notice this conversation owes, if any, before anything
472
+ * else is done with it: a revision whose own attempt to append it failed.
473
+ * One a holder appended before its save failed is not appended again.
474
+ */
475
+ private async announce(conversation: Conversation): Promise<void> {
476
+ const owed = conversation.announcing;
477
+ if (!owed) return;
478
+ // The model hasn't taken it in either way: that commits with clearing this.
479
+ const unread = (await this.config.store.getNotices(conversation.id, conversation.noticedThrough ?? 0)) ?? [];
480
+ if (!unread.some((n) => n.handle === owed.handle && n.replaces === owed.replaces)) await this.config.store.putNotice(owed);
481
+ conversation.announcing = null;
482
+ }
483
+
484
+ /** One `hai.update` write: take the lease, revise, let the conversation go. */
485
+ private async revise(
486
+ conversationId: string,
487
+ impl: AnySurfaceImpl,
488
+ handle: string,
489
+ job: UpdateJob,
490
+ ): Promise<{ handle: string; replaced: string }> {
491
+ const { store } = this.config;
492
+ // Loading an unknown id would start a new conversation, so make sure it
493
+ // exists first, with the cheapest read there is.
494
+ if ((await store.getNotices(conversationId, Number.MAX_SAFE_INTEGER, 1)) === null) {
495
+ throw new Error(`no conversation ${conversationId}`);
496
+ }
497
+ // A turn in progress lands first: wait for it, backing off, up to the timeout.
498
+ const deadline = Date.now() + job.timeoutMs;
499
+ let conversation: Conversation | null = null;
500
+ for (let wait = UPDATE_RETRY_MS; !conversation; wait = Math.min(wait * 2, UPDATE_RETRY_MAX_MS)) {
501
+ try {
502
+ conversation = await store.loadConversation(conversationId);
503
+ } catch (err) {
504
+ const left = deadline - Date.now();
505
+ if (!isConversationBusy(err) || left <= 0) throw err;
506
+ await new Promise((r) => setTimeout(r, Math.min(wait, left)));
507
+ // not another try once the timeout has passed, even if the turn has gone
508
+ if (Date.now() >= deadline) throw err;
509
+ }
510
+ }
511
+
512
+ let failure: unknown = null;
513
+ let written: { handle: string; replaced: string } | null = null;
514
+ try {
515
+ // one an earlier update still owes goes first, in order
516
+ await this.announce(conversation);
517
+ written = await this.reviseHeld(conversation, impl, handle, job);
518
+ } catch (err) {
519
+ failure = err;
520
+ }
521
+ // As a route does at the end of a request: let the conversation go, with
522
+ // the token kept so the save can prove this is still the rightful holder.
523
+ conversation.leaseUntil = null;
524
+ if (!isStaleLease(failure)) {
525
+ try {
526
+ await store.saveConversation(conversation);
527
+ } catch (err) {
528
+ failure ??= err;
529
+ }
530
+ }
531
+ if (failure) throw failure;
532
+ return written!;
533
+ }
534
+
535
+ private async reviseHeld(
536
+ conversation: Conversation,
537
+ impl: AnySurfaceImpl,
538
+ handle: string,
539
+ job: UpdateJob,
540
+ ): Promise<{ handle: string; replaced: string }> {
541
+ const { store } = this.config;
542
+ if (!conversation.handles.includes(handle)) throw new Error(`no surface ${handle} in conversation ${conversation.id}`);
543
+ // the surface as it is now: updates queued one behind another each revise the last
544
+ const current = currentHandle(conversation, handle);
545
+ if (conversation.frozen.includes(current)) throw new Error(`${current} was answered, so it can't be revised`);
546
+ const record = await store.getPayload(current, conversation.id);
547
+ if (!record) throw new Error(`no surface ${current} in conversation ${conversation.id}`);
548
+ if (record.component !== impl.surface.name) throw new Error(`${current} is a ${record.component}, not a ${impl.surface.name}`);
549
+ // After the reads, right before the write, as for `ctx.update`: a revision
550
+ // must not bring an out-of-date conversation back by superseding the
551
+ // surface that closed it.
552
+ if (await this.refuseIfExpired(conversation, () => {})) {
553
+ throw new Error("this conversation is out of date; nothing more can be revised in it");
554
+ }
555
+
556
+ const next = await store.putPayload(
557
+ {
558
+ conversationId: conversation.id,
559
+ component: impl.surface.name,
560
+ version: impl.surface.version,
561
+ props: job.props,
562
+ mode: record.mode,
563
+ staleAfterMs: windowOf(impl),
564
+ },
565
+ conversation.leaseToken,
566
+ );
567
+ const digest = impl.impl.digest(job.props, { handle: next });
568
+ // …and once more as it takes effect: the write and the digest take a while too
569
+ const window = strictest(record.staleAfterMs, windowOf(impl));
570
+ if (window !== "never" && !(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
571
+ throw new Error("this conversation went out of date while the revision was made; it was not applied");
572
+ }
573
+
574
+ conversation.handles.push(next);
575
+ (conversation.superseded ??= {})[current] = next;
576
+ // A question waiting on the surface waits on its revision. Its digest stays
577
+ // as the model was shown it: the update's text rides in the same message as
578
+ // the answer, after it, saying what changed.
579
+ if (conversation.pending?.handle === current) conversation.pending = { ...conversation.pending, handle: next };
580
+ // The notice it owes commits with it, in one fenced save, so the revision
581
+ // and its notice can't come apart. Saved before the notice is appended,
582
+ // which proves the lease is still held: a notice for a revision the history
583
+ // lost would show the browser a surface every click on is refused.
584
+ const notice = {
585
+ conversationId: conversation.id,
586
+ name: impl.surface.name,
587
+ version: impl.surface.version,
588
+ payload: null,
589
+ model: `${job.model ? `${job.model} ` : ""}${current} was replaced by ${next}; its earlier digest is out of date. Now: ${digest}`,
590
+ handle: next,
591
+ replaces: current,
592
+ ...(job.wake ? { kind: "wake" as const } : {}),
593
+ };
594
+ conversation.announcing = notice;
595
+ await store.saveConversation(conversation);
596
+ try {
597
+ await store.putNotice(notice);
598
+ } catch (err) {
599
+ // Still owed, and appended by whoever holds the conversation next, before
600
+ // anything else: the model hears of the revision before it runs again.
601
+ throw new Error(`${next} is saved, but its update notice could not be stored yet: it goes out with the conversation's next request (${(err as Error).message})`);
602
+ }
603
+ conversation.announcing = null;
604
+ return { handle: next, replaced: current };
605
+ }
606
+
228
607
  /**
229
608
  * Start a turn for the `wake` notices this conversation hasn't taken in, if
230
609
  * it can take one now. The events route calls this, and streams the turn
@@ -279,6 +658,7 @@ export class Hai {
279
658
  }
280
659
 
281
660
  private async wakeTurn(conversation: Conversation, emit: Emit): Promise<WakeOutcome> {
661
+ await this.announce(conversation);
282
662
  // a load of an unknown id starts a new conversation, which has not begun either
283
663
  if (!conversation.messages.length || conversation.status === "awaiting") return "idle";
284
664
  if (await this.refuseIfExpired(conversation, () => {})) return "idle";
@@ -373,11 +753,13 @@ export class Hai {
373
753
  conversation: Conversation,
374
754
  ): Promise<{ blocks: TextBlock[]; through: number; wake: number }> {
375
755
  const through = conversation.noticedThrough ?? 0;
376
- if (!this.notices.size) return { blocks: [], through, wake: 0 };
756
+ if (!this.sendsNotices) return { blocks: [], through, wake: 0 };
377
757
  const fresh = (await this.config.store.getNotices(conversation.id, through)) ?? [];
378
758
  return {
379
759
  blocks: fresh.flatMap((n) =>
380
- n.model === null ? [] : [{ type: "text" as const, text: `[App notification: ${n.name}] ${n.model}` }],
760
+ n.model === null
761
+ ? []
762
+ : [{ type: "text" as const, text: n.replaces === undefined ? `[App notification: ${n.name}] ${n.model}` : `[UI update] ${n.model}` }],
381
763
  ),
382
764
  through: fresh.at(-1)?.seq ?? through,
383
765
  // the newest unread wake notice, or 0
@@ -474,6 +856,7 @@ export class Hai {
474
856
  // ─────────────────────────────────────────────────── public API
475
857
 
476
858
  async send(conversation: Conversation, text: string, emit: Emit): Promise<void> {
859
+ await this.announce(conversation);
477
860
  if (await this.refuseIfExpired(conversation, emit)) return;
478
861
 
479
862
  // The first message is the one that starts the conversation.
@@ -523,6 +906,7 @@ export class Hai {
523
906
  */
524
907
  async start(conversation: Conversation, emit: Emit): Promise<void> {
525
908
  if (!this.config.init || conversation.messages.length > 0) return;
909
+ await this.announce(conversation);
526
910
  const unread = await this.unreadNotices(conversation);
527
911
  if (await this.runInit(conversation, withText(unread.blocks, STARTED), emit, unread.through)) return; // parked on its surface
528
912
  conversation.status = "idle";
@@ -619,6 +1003,7 @@ export class Hai {
619
1003
  input: { handle: string; action: string; value: unknown },
620
1004
  emit: Emit,
621
1005
  ): Promise<void> {
1006
+ await this.announce(conversation);
622
1007
  if (await this.refuseIfExpired(conversation, emit)) return;
623
1008
 
624
1009
  // The payload row carries a conversation id, but that alone is not enough.
@@ -757,8 +1142,9 @@ export class Hai {
757
1142
  */
758
1143
  private async refuseIfExpired(conversation: Conversation, emit: Emit): Promise<boolean> {
759
1144
  if (!conversation.handles.length) return false;
760
- const now = Date.now();
761
1145
  const records = await this.config.store.getPayloads(conversation.handles, conversation.id);
1146
+ // after the read, so a window that ran out while it was in flight counts
1147
+ const now = Date.now();
762
1148
 
763
1149
  // the surface that went out of date first is the one that closed it
764
1150
  let first: { expiredAt: number; age: number; window: number | undefined } | null = null;
@@ -866,8 +1252,18 @@ export class Hai {
866
1252
  // Hold the resolved siblings too — the API is all-or-nothing per batch.
867
1253
  conversation.status = "awaiting";
868
1254
  // At the question's latest revision: a later call in this reply may
869
- // have revised it.
870
- conversation.pending = { ...parked, handle: currentHandle(conversation, parked.handle), results };
1255
+ // have revised it, and then its answer goes out under the revision's
1256
+ // digest, not the original's.
1257
+ // Only the question's part is replaced: anything the asking call said
1258
+ // after it rides along as before.
1259
+ const handle = currentHandle(conversation, parked.handle);
1260
+ const asking = hop.asking;
1261
+ let digest = parked.digest;
1262
+ if (handle !== parked.handle && asking?.handle === handle && asking.digest !== undefined) {
1263
+ const asked = asking.question;
1264
+ digest = asked !== undefined && parked.digest.startsWith(asked) ? asking.digest + parked.digest.slice(asked.length) : asking.digest;
1265
+ }
1266
+ conversation.pending = { ...parked, handle, digest, results };
871
1267
  emit({ type: "status", status: "awaiting" });
872
1268
  await this.emitContext(conversation, emit);
873
1269
  return;
@@ -916,6 +1312,9 @@ export class Hai {
916
1312
  surfaceMode: "display" | "elicit",
917
1313
  // the handle this revises in place, for `update`
918
1314
  replaces?: string,
1315
+ // last word before the surface is attached and sent: throwing leaves the
1316
+ // stored payload unreferenced, inert as an overtaken turn's
1317
+ beforeAttach?: () => void,
919
1318
  ) => {
920
1319
  // Validate before storing: props may originate outside this process.
921
1320
  const parsed = impl.surface.props.parse(props);
@@ -941,6 +1340,7 @@ export class Hai {
941
1340
 
942
1341
  // The split, in two lines. Digest -> model. Payload -> browser.
943
1342
  const digest = impl.impl.digest(parsed, { handle });
1343
+ beforeAttach?.();
944
1344
  // Recorded only once the digest is in hand. A surface that fails here is
945
1345
  // never shown, and a handle in the history counts toward its freshness,
946
1346
  // so it could otherwise close the conversation over something unseen.
@@ -1000,6 +1400,7 @@ export class Hai {
1000
1400
  try {
1001
1401
  const ret = await show(impl, props, "elicit");
1002
1402
  claim.handle = ret.handle;
1403
+ claim.question = ret.model;
1003
1404
  asked = { handle: ret.handle, digest: ret.model };
1004
1405
  return ret;
1005
1406
  } catch (err) {
@@ -1028,11 +1429,33 @@ export class Hai {
1028
1429
  if (record.component !== impl.surface.name) {
1029
1430
  throw new Error(`${handle} is a ${record.component}, not a ${impl.surface.name}`);
1030
1431
  }
1031
- const ret = await show(impl, props, record.mode, handle);
1432
+ // Freshness again, last, right before the write: a long turn can
1433
+ // outlast a window that was open when it began, and so can the reads
1434
+ // above. A revision must not bring an out-of-date conversation back
1435
+ // by superseding the surface that closed it.
1436
+ if (await this.refuseIfExpired(conversation, () => {})) {
1437
+ throw new Error("this conversation is out of date; nothing more can be revised in it");
1438
+ }
1439
+ // …and once more as it takes effect: the write and the digest above can
1440
+ // take a while too, and the browser closes the conversation at the old
1441
+ // surface's deadline whether or not a revision is on its way.
1442
+ const window = strictest(record.staleAfterMs, windowOf(impl));
1443
+ const stillFresh = () => {
1444
+ if (window === "never") return;
1445
+ if (!(Number.isFinite(record.createdAt) && window !== undefined && record.createdAt + window > Date.now())) {
1446
+ throw new Error("this conversation went out of date while the revision was made; it was not applied");
1447
+ }
1448
+ };
1449
+ const ret = await show(impl, props, record.mode, handle, stillFresh);
1032
1450
  (conversation.superseded ??= {})[handle] = ret.handle!;
1033
- // a question waiting on the old surface waits on the new one
1034
- if (asked?.handle === handle) asked = { handle: ret.handle!, digest: ret.model };
1035
- if (hop.asking?.handle === handle) hop.asking.handle = ret.handle!;
1451
+ // a question waiting on the old surface waits on the new one; when this
1452
+ // call asked it, its result starts with the revision's digest now, so
1453
+ // that is the part a later call's revision replaces
1454
+ const mine = asked?.handle === handle;
1455
+ if (mine) asked = { handle: ret.handle!, digest: ret.model };
1456
+ if (hop.asking?.handle === handle) {
1457
+ Object.assign(hop.asking, { handle: ret.handle!, digest: ret.model }, mine ? { question: ret.model } : {});
1458
+ }
1036
1459
  return ret;
1037
1460
  } finally {
1038
1461
  revising.delete(handle);