@theoremjs/web 0.3.4-next.409.g25ad907 → 0.3.4-next.413.gb7a2b75

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/README.md CHANGED
@@ -185,17 +185,17 @@ await chat.submit();
185
185
  | Method | What it does |
186
186
  | --- | --- |
187
187
  | `submit()` | At `idle`, sends the draft. Otherwise, queues it. |
188
- | `enqueue('steer')` | Sends the draft to the running turn with `transport.steer`. The steer leaves `pendingMessages` when the turn's `stage` event names it in `injected`. A steer the turn never takes waits at the front of the queue. |
188
+ | `enqueue('steer')` | Sends the draft to the running turn with `transport.steer`. The steer leaves `pendingMessages` when the turn's `stage` event names it in `injected`, and joins the transcript there, between the reply before it and the reply after. A steer the turn never takes waits at the front of the queue. |
189
189
  | `enqueue('queue')` | Keeps the draft to send after the reply. When the reply ends without a failure and no gate waits, the first queued message is sent. |
190
190
  | `enqueue('stash')` | Keeps the draft without sending it. `queuePending(id)` moves it to the queue. |
191
- | `sendNow(message?)` | Sends a pending message, or the draft, at once. A streaming reply is stopped first. A reply that waits on gates is left. |
191
+ | `sendNow(message?)` | Sends a pending message, or the draft, at once. A streaming reply is stopped first, as `stop()` stops it. A reply that waits on gates is left. |
192
192
  | `restorePending(id)` | Puts a pending message back in the composer. The draft that was there is stashed first, so the user loses nothing. |
193
- | `stop()` | Stops the streaming reply. |
193
+ | `stop()` | Stops the streaming reply with `transport.stop`. What the reply said before the stop stays in the transcript and the history, closed as cancelled. A queued message stays queued. When the host refuses the stop, the controller drops the stream instead, and a host that keeps the conversation keeps the stopped reply where the page can't read it. |
194
194
  | `decide(action)`, `authenticate(secret?)` | Answer the gate that the reply waits on. |
195
195
  | `branch(index)` | Cuts the conversation after transcript block `index`. |
196
196
  | `sendText(text, files?)` | Sends a message from your code. It resolves with the blocks the turn added, or `null` when the chat cannot take a message now. |
197
197
 
198
- The controller uses these methods of the transport: `turn`, `invoke` (to answer a gate), `steer`, `conversation`, and `rewind`. `conversation` and `rewind` are called only for a profile whose host keeps the conversation. `pageResult` is called when the reply hands a tool to the page.
198
+ The controller uses these methods of the transport: `turn`, `invoke` (to answer a gate), `steer`, `stop`, `conversation`, and `rewind`. `conversation` and `rewind` are called only for a profile whose host keeps the conversation. `pageResult` is called when the reply hands a tool to the page.
199
199
 
200
200
  The controller writes no English text. The helpers below give the composer's controls as values, and you choose the words:
201
201
 
@@ -237,7 +237,7 @@ const transport = withPageToolDispatch(
237
237
  | `host` | App data for the tool handlers (`ctx.host`), such as the signed-in user. |
238
238
  | `pageToolBroker` | Delivers results to active page dispatches. Default: bounded process memory. |
239
239
  | `conversationStore` | Where the conversation is kept. Required, except for a stateless profile, which must not have one. See [Keep the conversation](#keep-the-conversation). |
240
- | `sessionStore`, `steerInbox` | Where open gates and queued steers are kept. Default: process memory. |
240
+ | `sessionStore`, `steerInbox` | Where open gates are kept, and where steers and stops reach a running turn. Default: process memory, which reaches only turns this process runs. On more than one instance, give a shared `SteerInbox`. |
241
241
  | `credentialStore` | Where tool credentials are kept, by session. Default: process memory. |
242
242
  | `authorizationUrl` | Returns the sign-in URL for an OAuth gate. Without it, an OAuth gate has no URL. |
243
243
  | `gateTtlMs` | How long a gate waits for an answer. Default: 30 minutes. A later answer fails with `session.gate_expired`. |
package/client/chat.js CHANGED
@@ -7,7 +7,7 @@ import { clientFailure, turnFailure } from "./failure.js";
7
7
  import { followGenerationDefaults } from "./generation-selection.js";
8
8
  import { warnPageToolMismatch } from "./live/live-page-tool.js";
9
9
  import { withPageToolDispatch } from "./page-dispatch.js";
10
- import { sessionAfter, stampWorked } from "./run-commit.js";
10
+ import { sessionAfter, stampWorked, stoppedReply } from "./run-commit.js";
11
11
  import { keepsConversation, resumeInterfaceTool, streamInterfaceDraftTurn, streamInterfaceTurn, } from "./run-session.js";
12
12
  import { isUnknownConversation, } from "./transport.js";
13
13
  import { foldAssistantTurn, issuedOperationId } from "./turn-client.js";
@@ -90,6 +90,8 @@ class Chat {
90
90
  #busy = false;
91
91
  #abort = null;
92
92
  #turnId = null;
93
+ /** The visitor stopped the run in flight, so the queue waits. */
94
+ #stopped = false;
93
95
  /** The run in flight; it settles, never rejects. */
94
96
  #run = null;
95
97
  // why: A stateless reply's work across its runs: a gate splits a reply into runs, and the wait between doesn't count.
@@ -170,8 +172,27 @@ class Chat {
170
172
  await this.#sendFields({ text: draftText, files: [...pendingFiles], voice: [...pendingVoice] });
171
173
  };
172
174
  stop = () => {
173
- this.#abort?.abort();
175
+ void this.#stop();
174
176
  };
177
+ /** Asks the host to stop the turn, so the reply it leaves comes back; a host that can't drops the stream. */
178
+ async #stop() {
179
+ const abort = this.#abort;
180
+ const turnId = this.#turnId;
181
+ if (!abort)
182
+ return;
183
+ this.#stopped = true;
184
+ if (!turnId) {
185
+ abort.abort();
186
+ return;
187
+ }
188
+ try {
189
+ await this.#options.transport.stop({ turnId });
190
+ }
191
+ catch {
192
+ // why: The host isn't running the turn here (it ended, or another instance runs it); the stream is dropped instead.
193
+ abort.abort();
194
+ }
195
+ }
175
196
  enqueue = async (kind) => {
176
197
  const { draftText, pendingFiles, pendingVoice } = this.#state;
177
198
  if (!composerHasPayload(draftText, pendingFiles, pendingVoice))
@@ -207,7 +228,7 @@ class Chat {
207
228
  if (!draft)
208
229
  return;
209
230
  if (this.#busy) {
210
- this.#abort?.abort();
231
+ await this.#stop();
211
232
  await this.#run;
212
233
  }
213
234
  this.#set({ pendingMessages: convertSteersToFrontQueued(this.#state.pendingMessages) });
@@ -652,6 +673,7 @@ class Chat {
652
673
  this.#busy = true;
653
674
  this.#abort = abort;
654
675
  this.#turnId = turnId ?? null;
676
+ this.#stopped = false;
655
677
  this.#drainArmed = false;
656
678
  // why: A message streams once it posts, so the previous reply never shows as streaming before it.
657
679
  this.#set({ failure: null, ...(message ? {} : { streaming: true }) });
@@ -691,7 +713,7 @@ class Chat {
691
713
  else {
692
714
  waits = this.#settleLocal(iface, outcome, { workedMs: this.#replyWorkedMs, endedAt });
693
715
  }
694
- this.#runEnded(!outcome.failure && !waits);
716
+ this.#runEnded(!outcome.failure && !waits && !this.#stopped);
695
717
  }
696
718
  catch (err) {
697
719
  // why: The run left no view to draw; the composer says so, and the chat stays as it stood.
@@ -775,7 +797,7 @@ class Chat {
775
797
  }
776
798
  this.#setDelivery(null);
777
799
  const closing = failure.aborted
778
- ? [{ id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } }]
800
+ ? stampWorked(stoppedReply(reply), worked)
779
801
  : withTurnError(reply, failure.error);
780
802
  this.#set({
781
803
  session,
package/client/index.d.ts CHANGED
@@ -33,5 +33,5 @@ export { hasLead, layoutResult, type ResultChart, type ResultFigure, type Result
33
33
  export type { AnsweringGate, ToolDecisionAction, ToolGateResolution, } from './tool-resume.ts';
34
34
  export { continueGatedToolInvocation, type PersonGateResolution } from './tool-resume.ts';
35
35
  export { createTraceFeed, type TraceFeed } from './trace-feed.ts';
36
- export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, postJson, postNdjson, readNdjsonStream, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
36
+ export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, postJson, postNdjson, readNdjsonStream, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, type TheoremStopRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
37
37
  export { buildInvokeRequest, buildTurnRequest, foldAssistantTurn, issuedOperationId, type RunScope, type WalkAway, } from './turn-client.ts';
@@ -56,6 +56,7 @@ export function withPageToolDispatch(transport, tools) {
56
56
  invoke: (request, onLine, signal) => transport.invoke(request, chatSink(onLine, signal), signal),
57
57
  turn: (request, onLine, signal) => transport.turn(request, chatSink(onLine, signal), signal),
58
58
  steer: transport.steer.bind(transport),
59
+ stop: transport.stop.bind(transport),
59
60
  conversation: transport.conversation.bind(transport),
60
61
  rewind: transport.rewind.bind(transport),
61
62
  };
@@ -32,3 +32,8 @@ export declare function stampWorked(blocks: TranscriptBlock[], worked: {
32
32
  workedMs: number;
33
33
  endedAt: number;
34
34
  }): TranscriptBlock[];
35
+ /**
36
+ * A reply the person stopped, as far as it went: what they saw stays, and it closes as cancelled
37
+ * unless the stream already closed it.
38
+ */
39
+ export declare function stoppedReply(blocks: TranscriptBlock[]): TranscriptBlock[];
@@ -47,3 +47,12 @@ export function stampWorked(blocks, worked) {
47
47
  return blocks;
48
48
  return blocks.map((block, index) => (index === last ? { ...block, ...worked } : block));
49
49
  }
50
+ /**
51
+ * A reply the person stopped, as far as it went: what they saw stays, and it closes as cancelled
52
+ * unless the stream already closed it.
53
+ */
54
+ export function stoppedReply(blocks) {
55
+ if (blocks.some((block) => block.kind === 'turn-done'))
56
+ return blocks;
57
+ return [...blocks, { id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } }];
58
+ }
@@ -120,6 +120,12 @@ export type TheoremSteerRequest = {
120
120
  };
121
121
  /** A `/steer` body as `createTheoremHandler` reads it. */
122
122
  export declare const theoremSteerRequestSchema: z.ZodType<TheoremSteerRequest>;
123
+ /** Stop an in-flight turn where it stands; its stream ends with what the reply left. */
124
+ export type TheoremStopRequest = {
125
+ turnId: string;
126
+ };
127
+ /** A `/stop` body as `createTheoremHandler` reads it. */
128
+ export declare const theoremStopRequestSchema: z.ZodType<TheoremStopRequest>;
123
129
  /**
124
130
  * The ref the store issued for the conversation a run wrote: the client sends
125
131
  * it with its next request. It comes before the run's error, which ends the stream.
@@ -181,6 +187,8 @@ export interface TheoremTransport {
181
187
  turn(request: TheoremTurnRequest, onLine: ChatLineSink, signal?: AbortSignal): Promise<void>;
182
188
  invoke(request: TheoremInvokeRequest, onLine: ChatLineSink, signal?: AbortSignal): Promise<void>;
183
189
  steer(request: TheoremSteerRequest): Promise<void>;
190
+ /** Stops the turn; refused (`session.turn_ended`) when this host is not running it. */
191
+ stop(request: TheoremStopRequest): Promise<void>;
184
192
  /** The conversation under `ref`, as the host shows it. */
185
193
  conversation(request: TheoremConversationRequest, signal?: AbortSignal): Promise<ConversationView>;
186
194
  /** The conversation cut after a transcript block, under the ref the host issued for it. */
@@ -83,6 +83,10 @@ const theoremSteerRequest = z.object({
83
83
  true;
84
84
  /** A `/steer` body as `createTheoremHandler` reads it. */
85
85
  export const theoremSteerRequestSchema = theoremSteerRequest;
86
+ const theoremStopRequest = z.object({ turnId: clientId });
87
+ true;
88
+ /** A `/stop` body as `createTheoremHandler` reads it. */
89
+ export const theoremStopRequestSchema = theoremStopRequest;
86
90
  const conversationLine = z.object({
87
91
  type: z.literal('conversation'),
88
92
  ref: z.string().min(1),
@@ -339,5 +343,6 @@ export function createHttpTransport(options = {}) {
339
343
  rewind: (body, signal) => fetchRewound(`${base}/rewind`, body, { ...options, signal }),
340
344
  pageResult: (body, signal) => postJson(`${base}/page-result`, body, { ...options, signal }),
341
345
  steer: (body) => postJson(`${base}/steer`, body, options),
346
+ stop: (body) => postJson(`${base}/stop`, body, options),
342
347
  };
343
348
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/web",
3
- "version": "0.3.4-next.409.g25ad907",
3
+ "version": "0.3.4-next.413.gb7a2b75",
4
4
  "description": "Theorem on the web platform, with no UI framework: Web-standard server handlers that serve a profile, and the browser client that talks to them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,6 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "zod": "^4.6.5",
28
- "@theoremjs/agents": "^0.3.4-next.409.g25ad907"
28
+ "@theoremjs/agents": "^0.3.4-next.413.gb7a2b75"
29
29
  }
30
30
  }
@@ -19,7 +19,7 @@ import { sanitizeTurnRequestWithEvents, withPublicLabels } from '@theoremjs/agen
19
19
  import { forClient } from '@theoremjs/agents/host';
20
20
  import { appendAssistantEventsToHistory, appendUserDraftToHistory, applyTurnEventsToSession, emptyInterfaceTurnSession, foldTurnEvents, gatedToolsFromEvents, historyFromTranscriptBlocks, prepareUserTurn, settlesToolCall, streamThoughtsEnabled, } from '@theoremjs/agents/interface';
21
21
  import { base64ToBytes } from '@theoremjs/agents/kernel';
22
- import { stampWorked } from "../client/run-commit.js";
22
+ import { stampWorked, stoppedReply } from "../client/run-commit.js";
23
23
  import { emptyConversation, unknownConversation, } from "./conversation-store.js";
24
24
  import { kernelTurnInput } from "./turn-input.js";
25
25
  /** The paused call a reference names, when its call id and its operation both match. */
@@ -166,15 +166,15 @@ function metered(conversation, events) {
166
166
  ...(applied.historyTokens === undefined ? {} : { historyTokens: applied.historyTokens }),
167
167
  };
168
168
  }
169
- /** The reply is over: its blocks join the transcript and its events the history. */
170
- function committed(host, conversation, reply) {
169
+ /** The reply is over: its blocks, as `closing` ends them, join the transcript and its events the history. */
170
+ function committed(host, conversation, reply, closing = (blocks) => blocks) {
171
171
  const { paused: _paused, providerState: _state, ...rest } = conversation;
172
172
  return {
173
173
  ...rest,
174
174
  ...metered(conversation, reply.events),
175
175
  transcript: [
176
176
  ...conversation.transcript,
177
- ...stampWorked(foldedFor(host, reply.events), {
177
+ ...stampWorked(closing(foldedFor(host, reply.events)), {
178
178
  workedMs: reply.workedMs,
179
179
  endedAt: Date.now(),
180
180
  }),
@@ -210,19 +210,11 @@ function failed(host, conversation, reply, failure) {
210
210
  };
211
211
  }
212
212
  /**
213
- * The person stopped the reply: it leaves the transcript, and the history
214
- * keeps what came before this run, the calls it settled included.
213
+ * The person stopped the reply: what they saw of it stays, in the transcript and the history
214
+ * alike, closed as cancelled. A call still running when it stopped enters neither.
215
215
  */
216
- function stopped(conversation, reply) {
217
- const { paused: _paused, ...rest } = conversation;
218
- return {
219
- ...rest,
220
- transcript: [
221
- ...conversation.transcript,
222
- { id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } },
223
- ],
224
- history: appendAssistantEventsToHistory(reply.base, reply.events.slice(reply.baseEvents, Math.max(reply.baseEvents, reply.seed))),
225
- };
216
+ function stopped(host, conversation, reply) {
217
+ return committed(host, conversation, reply, stoppedReply);
226
218
  }
227
219
  /**
228
220
  * Runs the model for `reply` and writes what it leaves. A compaction moves the
@@ -258,14 +250,17 @@ async function* runReply(host, opened, reply, runner, input, withMessage) {
258
250
  }
259
251
  catch (err) {
260
252
  current = await save(host, current, runner.signal.aborted
261
- ? stopped(conversation, worked())
253
+ ? stopped(host, conversation, worked())
262
254
  : failed(host, conversation, worked(), toErrorEvent(err)));
263
255
  yield* refLine(current);
264
- throw err;
256
+ // why: A stopped reply is no failure; the browser draws what it left from the ref.
257
+ if (!runner.signal.aborted)
258
+ throw err;
259
+ return;
265
260
  }
266
261
  const settled = worked();
267
262
  if (runner.signal.aborted) {
268
- current = await save(host, current, stopped(conversation, settled));
263
+ current = await save(host, current, stopped(host, conversation, settled));
269
264
  }
270
265
  else if (errorEvent) {
271
266
  current = await save(host, current, failed(host, conversation, settled, errorEvent));
@@ -396,7 +391,6 @@ function pausedReply(paused) {
396
391
  base: paused.base,
397
392
  events: [...paused.events],
398
393
  baseEvents: paused.baseEvents,
399
- seed: paused.events.length,
400
394
  workedMs: paused.workedMs,
401
395
  };
402
396
  }
@@ -443,7 +437,6 @@ export async function* messageReply(args) {
443
437
  base: withMessage(conversation.history),
444
438
  events: [],
445
439
  baseEvents: 0,
446
- seed: 0,
447
440
  workedMs: 0,
448
441
  };
449
442
  yield* runReply(host, opened, reply, runner, {
@@ -478,7 +471,6 @@ export async function* answerReply(args) {
478
471
  yield event;
479
472
  }
480
473
  reply.workedMs += Date.now() - startedAt;
481
- reply.seed = reply.events.length;
482
474
  if (error || gatedToolsFromEvents(reply.events).length) {
483
475
  // why: An answer that failed before its call settled leaves the reply as it was, to answer again.
484
476
  const settledCall = reply.events.some((event) => settlesToolCall(event, args.call.callId));
package/server/handler.js CHANGED
@@ -38,7 +38,7 @@ import { caughtStatus, clientError, forClient, HTTP_METHOD, } from '@theoremjs/a
38
38
  import { clientProfileInterface, gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
39
39
  import { answerGatedCall, resolveGateTtlMs, } from '@theoremjs/agents/kernel';
40
40
  import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
41
- import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
41
+ import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
42
42
  import { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, requiredOperationId, rewindConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
43
43
  import { unknownConversation } from "./conversation-store.js";
44
44
  import { createMemoryCredentialStore, } from "./credential-store.js";
@@ -50,7 +50,7 @@ import { boundedRequestJson, requestByteLimit } from "./request-body.js";
50
50
  import { checkRequest } from "./request-check.js";
51
51
  import { resolveSessionLimits } from "./session-limits.js";
52
52
  import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
53
- import { createMemorySteerInbox, steerStage, steerUnitOf } from "./steer-inbox.js";
53
+ import { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
54
54
  import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
55
55
  const NDJSON_HEADERS = {
56
56
  'content-type': 'application/x-ndjson; charset=utf-8',
@@ -90,6 +90,7 @@ function routeOf(request) {
90
90
  return last === 'turn' ||
91
91
  last === 'invoke' ||
92
92
  last === 'steer' ||
93
+ last === 'stop' ||
93
94
  last === 'conversation' ||
94
95
  last === 'rewind' ||
95
96
  last === 'call' ||
@@ -386,13 +387,14 @@ async function* once(event, labels) {
386
387
  yield withPublicLabels(event, labels);
387
388
  }
388
389
  /** One model run of a chat's reply, its gates recorded in the session as they pause. */
389
- async function* chatTurn(ctx, request, session, turn, turnId) {
390
+ async function* chatTurn(ctx, request, session, turn, key, stop) {
390
391
  const state = await ctx.sessions.read(session.id);
391
392
  const provider = await providerFor(ctx, request, turn.model);
392
393
  const host = await ctx.options.host?.(request);
393
- const key = turnId ? inboxKey(session.id, turnId) : undefined;
394
- if (key)
394
+ if (key) {
395
395
  await ctx.inbox.open(key);
396
+ stopOnRequest(ctx.inbox, key, stop);
397
+ }
396
398
  try {
397
399
  const events = runTurn({
398
400
  profile: ctx.profile.id,
@@ -402,7 +404,7 @@ async function* chatTurn(ctx, request, session, turn, turnId) {
402
404
  ...(turn.providerState ? { providerState: turn.providerState } : {}),
403
405
  sessionPermissions: state.permissions,
404
406
  credentials: sessionCredentials(ctx, session.id),
405
- signal: request.signal,
407
+ signal: AbortSignal.any([request.signal, stop.signal]),
406
408
  host,
407
409
  ...(turn.model ? { model: turn.model } : {}),
408
410
  ...(turn.effort ? { effort: turn.effort } : {}),
@@ -419,24 +421,27 @@ async function* chatTurn(ctx, request, session, turn, turnId) {
419
421
  await ctx.inbox.close(key);
420
422
  }
421
423
  }
422
- /** How this request runs the model for a reply: its generation, and the request's abort. */
424
+ /** How this request runs the model for a reply: its generation, and its abort (the request's, or a stop). */
423
425
  function chatRunner(ctx, request, session, generation) {
426
+ const key = generation.turnId ? inboxKey(session.id, generation.turnId) : undefined;
427
+ const stop = new AbortController();
428
+ const signal = AbortSignal.any([request.signal, stop.signal]);
424
429
  return {
425
- run: (turn) => chatTurn(ctx, request, session, turn, generation.turnId),
430
+ run: (turn) => chatTurn(ctx, request, session, turn, key, stop),
426
431
  async compact(history, tokens, model) {
427
432
  const provider = await providerFor(ctx, request, model);
428
433
  const compacted = await compactHistory({
429
434
  profile: ctx.profile.id,
430
435
  history,
431
436
  tokens,
432
- signal: request.signal,
437
+ signal,
433
438
  ...(model ? { model } : {}),
434
439
  }, provider);
435
440
  return compacted?.history;
436
441
  },
437
442
  ...(generation.model ? { model: generation.model } : {}),
438
443
  ...(generation.effort ? { effort: generation.effort } : {}),
439
- signal: request.signal,
444
+ signal,
440
445
  };
441
446
  }
442
447
  /** The chat's conversation as this session's runs read and write it; undefined when the profile keeps none. */
@@ -541,6 +546,17 @@ async function steer(ctx, request, session) {
541
546
  }
542
547
  return jsonResponse(200, { ok: true });
543
548
  }
549
+ /** Stops a running turn where it stands; its stream goes on to save and send what the reply left. */
550
+ async function stop(ctx, request, session) {
551
+ const body = await readBody(request, theoremStopRequestSchema, ctx.options.maxRequestBytes);
552
+ if (!(await ctx.inbox.stop(inboxKey(session.id, body.turnId)))) {
553
+ // labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
554
+ throw new TheoremError('request', `turn ${body.turnId} is not running`, {
555
+ copy: { key: 'session.turn_ended' },
556
+ });
557
+ }
558
+ return jsonResponse(200, { ok: true });
559
+ }
544
560
  /** One call of one of a host's tools, run as the page asked it; a gate it pauses on waits in the session. */
545
561
  async function* callEvents(ctx, request, session, body) {
546
562
  const state = await ctx.sessions.read(session.id);
@@ -688,6 +704,8 @@ async function route(ctx, request, session) {
688
704
  return conversation(ctx, request, session);
689
705
  if (target === 'rewind')
690
706
  return rewind(ctx, request, session);
707
+ if (target === 'stop')
708
+ return stop(ctx, request, session);
691
709
  return steer(ctx, request, session);
692
710
  }
693
711
  export function createTheoremHandler(options) {
package/server/mod.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export { theoremHostCallRequestSchema } from '../client/host-transport.ts';
9
9
  export type { LiveClientMessage, LiveOpenMessage } from '../client/live-messages.ts';
10
- export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
10
+ export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
11
11
  export { answerReply, type ConversationHost, type ConversationUse, conversationView, type IssuedWalkAway, messageReply, type OpenedConversation, openConversation, type PostedMessage, postedMessage, type ReplyLine, type ReplyRunner, type ReplyTurn, readerConversationHost, rewindConversation, startConversation, statelessRefusal, type WalkedAwayCall, waitingCalls, } from './conversation.ts';
12
12
  export { type ConversationSnapshot, type ConversationWriteResult, createMemoryConversationStore, createSealedConversationStore, emptyConversation, type PausedReply, type SealedConversationStoreOptions, type StoredConversation, storedConversationSchema, type TheoremConversationStore, unknownConversation, } from './conversation-store.ts';
13
13
  export { createMemoryCredentialStore, type TheoremCredentialStore, type TheoremCredentials, } from './credential-store.ts';
@@ -22,6 +22,6 @@ export { createTheoremSessionBoundary, type PreparedLiveOpen, type SessionBounda
22
22
  export type { TheoremSessionLimits } from './session-limits.ts';
23
23
  export { resolveSessionLimits } from './session-limits.ts';
24
24
  export { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, type MemorySessionStoreOptions, type PendingToolGate, type SessionSnapshot, type SessionToolOperation, type SessionWriteResult, type TheoremSessionState, type TheoremSessionStore, theoremSessionStateSchema, } from './session-store.ts';
25
- export { createMemorySteerInbox, type SteerInbox, type SteerUnit, steerStage, steerUnitOf, } from './steer-inbox.ts';
25
+ export { createMemorySteerInbox, type SteerInbox, type SteerUnit, steerStage, steerUnitOf, stopOnRequest, } from './steer-inbox.ts';
26
26
  export { sessionToolCoordinator } from './tool-operations.ts';
27
27
  export { kernelTurnInput, liveSessionOpen } from './turn-input.ts';
package/server/mod.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * @module
7
7
  */
8
8
  export { theoremHostCallRequestSchema } from "../client/host-transport.js";
9
- export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
9
+ export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
10
10
  export { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, rewindConversation, startConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
11
11
  export { createMemoryConversationStore, createSealedConversationStore, emptyConversation, storedConversationSchema, unknownConversation, } from "./conversation-store.js";
12
12
  export { createMemoryCredentialStore, } from "./credential-store.js";
@@ -20,6 +20,6 @@ export { sessionStateStore } from "./session-authority.js";
20
20
  export { createTheoremSessionBoundary, } from "./session-boundary.js";
21
21
  export { resolveSessionLimits } from "./session-limits.js";
22
22
  export { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, theoremSessionStateSchema, } from "./session-store.js";
23
- export { createMemorySteerInbox, steerStage, steerUnitOf, } from "./steer-inbox.js";
23
+ export { createMemorySteerInbox, steerStage, steerUnitOf, stopOnRequest, } from "./steer-inbox.js";
24
24
  export { sessionToolCoordinator } from "./tool-operations.js";
25
25
  export { kernelTurnInput, liveSessionOpen } from "./turn-input.js";
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Mid-turn steer inbox — the client posts user messages while a turn streams;
3
- * the turn's stage handler drains one unit per inject-capable stage.
3
+ * the turn's stage handler drains one unit per inject-capable stage. The
4
+ * client's stop reaches the turn through it too.
4
5
  *
5
6
  * @module
6
7
  */
@@ -21,8 +22,8 @@ export declare function steerUnitOf(steer: Pick<TheoremSteerRequest, 'id' | 'inj
21
22
  export declare function steerStage(inbox: SteerInbox, key: string): StageHandler;
22
23
  /**
23
24
  * Keyed by client turn id. The default lives in process memory, which is only
24
- * correct when steer POSTs reach the same process as the turn — swap in a
25
- * shared store (KV, Redis, Durable Object) for multi-instance hosts.
25
+ * correct when steer and stop POSTs reach the same process as the turn — swap
26
+ * in a shared store (KV, Redis, Durable Object) for multi-instance hosts.
26
27
  */
27
28
  export interface SteerInbox {
28
29
  open(turnId: string): void | Promise<void>;
@@ -30,6 +31,12 @@ export interface SteerInbox {
30
31
  enqueue(turnId: string, unit: SteerUnit): boolean | Promise<boolean>;
31
32
  /** Next unit, FIFO, or `undefined`. */
32
33
  consume(turnId: string): SteerUnit | undefined | Promise<SteerUnit | undefined>;
34
+ /** Stops the turn. Returns `false` when no turn with this id is open. */
35
+ stop(turnId: string): boolean | Promise<boolean>;
36
+ /** Settles `true` once the turn is stopped (at once if it already is), `false` once it closes. */
37
+ stopped(turnId: string): Promise<boolean>;
33
38
  close(turnId: string): void | Promise<void>;
34
39
  }
35
40
  export declare function createMemorySteerInbox(): SteerInbox;
41
+ /** Aborts `stop` once the inbox's turn is stopped. */
42
+ export declare function stopOnRequest(inbox: SteerInbox, turnId: string, stop: AbortController): void;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Mid-turn steer inbox — the client posts user messages while a turn streams;
3
- * the turn's stage handler drains one unit per inject-capable stage.
3
+ * the turn's stage handler drains one unit per inject-capable stage. The
4
+ * client's stop reaches the turn through it too.
4
5
  *
5
6
  * @module
6
7
  */
@@ -29,24 +30,54 @@ export function steerStage(inbox, key) {
29
30
  };
30
31
  }
31
32
  export function createMemorySteerInbox() {
32
- const queues = new Map();
33
+ const turns = new Map();
33
34
  return {
34
35
  open(turnId) {
35
- if (!queues.has(turnId))
36
- queues.set(turnId, []);
36
+ if (!turns.has(turnId))
37
+ turns.set(turnId, { queue: [], stopped: false, settle: new Set() });
37
38
  },
38
39
  enqueue(turnId, unit) {
39
- const queue = queues.get(turnId);
40
- if (!queue)
40
+ const turn = turns.get(turnId);
41
+ if (!turn)
41
42
  return false;
42
- queue.push(structuredClone(unit));
43
+ turn.queue.push(structuredClone(unit));
43
44
  return true;
44
45
  },
45
46
  consume(turnId) {
46
- return queues.get(turnId)?.shift();
47
+ return turns.get(turnId)?.queue.shift();
48
+ },
49
+ stop(turnId) {
50
+ const turn = turns.get(turnId);
51
+ if (!turn)
52
+ return false;
53
+ turn.stopped = true;
54
+ for (const settle of turn.settle)
55
+ settle(true);
56
+ turn.settle.clear();
57
+ return true;
58
+ },
59
+ stopped(turnId) {
60
+ const turn = turns.get(turnId);
61
+ if (!turn)
62
+ return Promise.resolve(false);
63
+ if (turn.stopped)
64
+ return Promise.resolve(true);
65
+ return new Promise((resolve) => turn.settle.add(resolve));
47
66
  },
48
67
  close(turnId) {
49
- queues.delete(turnId);
68
+ const turn = turns.get(turnId);
69
+ for (const settle of turn?.settle ?? [])
70
+ settle(false);
71
+ turns.delete(turnId);
50
72
  },
51
73
  };
52
74
  }
75
+ /** Aborts `stop` once the inbox's turn is stopped. */
76
+ export function stopOnRequest(inbox, turnId, stop) {
77
+ inbox.stopped(turnId).then((stopped) => {
78
+ if (stopped)
79
+ stop.abort();
80
+ },
81
+ // why: An inbox that can't say leaves the turn to run; the browser's fallback drops the stream.
82
+ () => { });
83
+ }