@mercury-fw/channel-http 0.9.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @mercury-fw/channel-http
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b2f8cfb: - A confirmation can offer several choices (for example "only this time" / "always for this host"): a plugin tool stages them with the new `stageChoices` of `SessionToolContext`, and gets one token per choice. The person takes one; the others die with it, and they all expire together.
8
+ - The paper-trail note of a group of choices is one, under its first token, and says which choice was taken.
9
+ - `detectPendingConfirmation` returns the `choices` a staging offers; a plain staging is one choice labelled `Conferma`.
10
+ - A confirmed action can hand its result back to the model (`resume` on a choice): instead of showing the person the raw result, the channel resumes the turn and the model answers on it, the result reaching it as the tool result of the call that staged it, never stored in the history.
11
+ - A resumed turn answers the person who confirmed even in a shared space, and the line standing for the confirmation isn't archived as something they said. A result nobody resumes within five minutes (the life of a confirmation token) is forgotten, and a restart loses it.
12
+ - `InboundTurn.resumeAfterConfirm` and `RESUME_AFTER_CONFIRM` (channel contract) carry the resumed turn; `ConfirmOutcome` has a `resume` status.
13
+ - Terminal: every choice is printed on its own line with its token; a confirmation that resumes runs the turn again.
14
+ - Google Chat: the confirmation card has one button per choice; a typed token or a clicked choice that resumes runs the turn again, in the session's queue. The channel now needs channel-types 0.51.0 or later.
15
+ - HTTP: the `pending` event carries `choices`; a token sent to `POST /turn` that resumes streams the model's answer on the same request; `POST /confirm` answers `resume: true`, and `POST /turn { resume: true, conversationId }` runs that turn. The channel now needs channel-types 0.51.0 or later.
16
+
3
17
  ## 0.9.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -45,8 +45,9 @@ Runs one conversational turn; the reply streams back as Server-Sent Events.
45
45
 
46
46
  | field | type | required | description |
47
47
  |---|---|---|---|
48
- | `text` | string | yes, unless `opening` | The user message. A bare confirmation token here confirms a staged action (see the `pending` event) without invoking the model. |
48
+ | `text` | string | yes, unless `opening` or `resume` | The user message. A bare confirmation token here confirms a staged action (see the `pending` event) without invoking the model; when the action hands its result back to the model, the model's answer streams on this same request. |
49
49
  | `opening` | `true` | no | The assistant speaks first, before the person writes anything: what a UI asks right after someone signs in, when `GET /me` says the assistant doesn't know their name. It introduces itself and asks how to call them, and saves the answer once they give it. For someone it already knows, the stream is a single `final` with an empty text. Needs `conversationId`; `text` is ignored. |
50
+ | `resume` | `true` | no | The turn a `POST /confirm` answered `resume: true` for: the model answers on what the confirmed action handed back. Needs `conversationId`; `text` is ignored. With nothing to resume, the stream is a single `final` with an empty text. |
50
51
  | `conversationId` | string | no | Opaque, client-owned id that continues one of your conversations: letters, digits, `-` and `_`, up to 128 characters (a UUID fits) once surrounding whitespace is trimmed, `400` otherwise. Sent again, however long after, it continues that conversation, rebuilt from the archive if Mercury had freed it (see `HTTP_SURFACE_RESUME_MAX_CHARS`). Omitted ⇒ a fresh one-off session that can't be continued. |
51
52
 
52
53
  **Responses**: `200 text/event-stream` (the events below); `400` if `text` is missing, an opening has no `conversationId`, the body isn't JSON, or `conversationId` has other characters than the ones above.
@@ -62,7 +63,7 @@ Reasoning and answer text arrive as **incremental deltas** — never one finishe
62
63
  | `tool` | `{ label, detail, toolCallId, name }` | a tool call starts; `name` is the tool the model called, absent when Mercury is saving something to memory |
63
64
  | `tool_finish` | `{ toolCallId, outcome }` | a tool call settles (`outcome`: `success` \| `failed` \| `pending`) |
64
65
  | `text` | `{ chunk }` | an answer-text delta |
65
- | `pending` | `{ command, token }` | a confirm-required action was staged; send `token` back to confirm it — as a later `/turn` `text`, or via `POST /confirm` |
66
+ | `pending` | `{ command, token, choices }` | a confirm-required action was staged; send a token back to confirm it — as a later `/turn` `text`, or via `POST /confirm`. `choices` is every way to answer (`{ token, label }`, in order; one, labelled `Conferma`, for a plain staging): the others die with the one sent |
66
67
  | `login` | `{ service, url }` | the caller isn't logged in to `service` yet, which a plugin acts on as them: show them `url` to open (the model never gets it) |
67
68
  | `final` | `{ text }` | the complete answer (also emitted for a token confirmation, with no model turn) |
68
69
  | `error` | `{ message }` | the turn failed mid-stream |
@@ -86,7 +87,7 @@ A code for linking another of the caller's accounts to them, for the UI to show:
86
87
 
87
88
  ## `POST /confirm`
88
89
 
89
- Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
90
+ Explicit alternative to re-sending a token as `/turn` `text`. Body `{ token, conversationId }`; returns `{ ok: true, resolved: true, text }` when the token was a pending confirmation, `{ ok: true, resolved: false }` otherwise. When the confirmed action hands its result back to the model, it returns `{ ok: true, resolved: true, resume: true }` instead: open `POST /turn { resume: true, conversationId }` to hear the model's answer. `400` if `token` or `conversationId` is missing, or the id has other characters than `/turn` accepts. Never invokes the model.
90
91
 
91
92
  ## Read-only introspection
92
93
 
@@ -64,7 +64,9 @@ export declare function openApiResponse(corsOrigin?: string): Response;
64
64
  * `{ opening: true, conversationId }`, with no `text`, asks the assistant to
65
65
  * speak first in that conversation (right after someone signs in): the core
66
66
  * decides whether it has anything to say, and a `final` with an empty text
67
- * means it hasn't.
67
+ * means it hasn't. `{ resume: true, conversationId }` runs the turn a
68
+ * `/confirm` answered `resume: true` for; a bare token whose confirmation
69
+ * resumes runs that turn on the same stream.
68
70
  */
69
71
  export declare function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response>;
70
72
  export type ConfirmRequestDeps = {
@@ -83,7 +85,9 @@ export type ConfirmRequestDeps = {
83
85
  * pending confirmation and its staged action was consumed and run; the `text`
84
86
  * then reports whether that execution succeeded. `resolved: false` means the
85
87
  * token was not a pending confirmation (unknown, expired, already used, or not
86
- * even token-shaped). Never touches the model.
88
+ * even token-shaped). `resume: true` (with no `text`) means the action handed
89
+ * its result back to the model: the client hears its answer by opening
90
+ * `/turn { resume: true, conversationId }`. Never touches the model.
87
91
  */
88
92
  export declare function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response>;
89
93
  export type LinkRequestDeps = {
package/http-server.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  detectLoginRequired,
27
27
  detectPendingConfirmation,
28
28
  PENDING_CONFIRMATION_NOTE,
29
+ RESUME_AFTER_CONFIRM,
29
30
  type HandleTurn,
30
31
  type TurnSink,
31
32
  type Authenticate,
@@ -151,14 +152,16 @@ export function openApiResponse(corsOrigin = "*"): Response {
151
152
  * `{ opening: true, conversationId }`, with no `text`, asks the assistant to
152
153
  * speak first in that conversation (right after someone signs in): the core
153
154
  * decides whether it has anything to say, and a `final` with an empty text
154
- * means it hasn't.
155
+ * means it hasn't. `{ resume: true, conversationId }` runs the turn a
156
+ * `/confirm` answered `resume: true` for; a bare token whose confirmation
157
+ * resumes runs that turn on the same stream.
155
158
  */
156
159
  export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Promise<Response> {
157
160
  const origin = deps.corsOrigin ?? "*";
158
161
  const cors = corsHeaders(origin);
159
162
  const principal = await caller(req, deps, origin);
160
163
  if (principal instanceof Response) return principal;
161
- let body: { text?: unknown; conversationId?: unknown; opening?: unknown };
164
+ let body: { text?: unknown; conversationId?: unknown; opening?: unknown; resume?: unknown };
162
165
  try {
163
166
  body = (await req.json()) as typeof body;
164
167
  } catch {
@@ -167,13 +170,18 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
167
170
  const opening = body.opening === true;
168
171
  // A conversation the client names is one it reopens; an ephemeral one never comes back.
169
172
  const named = typeof body.conversationId === "string" && body.conversationId.trim().length > 0;
173
+ // The turn a `/confirm` answered `resume: true` for.
174
+ const resume = body.resume === true;
170
175
  if (opening && !named) {
171
176
  return Response.json({ ok: false, error: "an opening needs a conversationId" }, { status: 400, headers: cors });
172
177
  }
173
- if (!opening && (typeof body.text !== "string" || body.text.trim().length === 0)) {
178
+ if (resume && !named) {
179
+ return Response.json({ ok: false, error: "a resumed turn needs a conversationId" }, { status: 400, headers: cors });
180
+ }
181
+ if (!opening && !resume && (typeof body.text !== "string" || body.text.trim().length === 0)) {
174
182
  return Response.json({ ok: false, error: "missing text" }, { status: 400, headers: cors });
175
183
  }
176
- const text = opening ? "" : (body.text as string);
184
+ const text = opening || resume ? "" : (body.text as string);
177
185
  const conversationId = named ? (body.conversationId as string).trim() : (deps.newSessionKey ?? (() => crypto.randomUUID()))();
178
186
  if (!CONVERSATION_ID.test(conversationId)) {
179
187
  return Response.json({ ok: false, error: "invalid conversationId" }, { status: 400, headers: cors });
@@ -207,12 +215,14 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
207
215
 
208
216
  // Same deterministic interception as every other channel — a
209
217
  // previously-approved mutation must never depend on the model.
210
- const confirmReply = opening ? null : await deps.confirm(text, sessionKey, principal);
211
- if (confirmReply !== null) {
218
+ const confirmReply = opening || resume ? null : await deps.confirm(text, sessionKey, principal);
219
+ if (confirmReply !== null && confirmReply !== RESUME_AFTER_CONFIRM) {
212
220
  send("final", { text: confirmReply });
213
221
  close();
214
222
  return;
215
223
  }
224
+ // The confirmed action's result goes back to the model: the turn runs again, on this stream.
225
+ const resuming = resume || confirmReply === RESUME_AFTER_CONFIRM;
216
226
 
217
227
  const sink: TurnSink = {
218
228
  onToolStart: (label, detail, toolCallId, toolName) => send("tool", { label, detail, toolCallId, name: toolName }),
@@ -224,7 +234,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
224
234
  onReasoningEnd: (id, failed) => send("reasoning_end", { id, failed }),
225
235
  onStep: (step) => {
226
236
  const pending = detectPendingConfirmation(step);
227
- if (pending) send("pending", { command: pending.summary, token: pending.token });
237
+ if (pending) send("pending", { command: pending.summary, token: pending.token, choices: pending.choices });
228
238
  const login = detectLoginRequired(step);
229
239
  if (login) send("login", { service: login.service, url: login.url });
230
240
  },
@@ -238,7 +248,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
238
248
  {
239
249
  channel: "http",
240
250
  multiUser: false,
241
- text,
251
+ text: resuming ? "" : text,
242
252
  sessionKey,
243
253
  principal,
244
254
  // The caller's id comes from the auth provider, unchecked here.
@@ -246,6 +256,7 @@ export async function handleTurnRequest(req: Request, deps: TurnRequestDeps): Pr
246
256
  abortSignal: abort.signal,
247
257
  ...(named ? { resume: deps.resumeMaxChars === undefined ? {} : { maxChars: deps.resumeMaxChars } } : {}),
248
258
  ...(opening ? { opening: true as const } : {}),
259
+ ...(resuming ? { resumeAfterConfirm: true as const } : {}),
249
260
  },
250
261
  sink,
251
262
  );
@@ -285,7 +296,9 @@ export type ConfirmRequestDeps = {
285
296
  * pending confirmation and its staged action was consumed and run; the `text`
286
297
  * then reports whether that execution succeeded. `resolved: false` means the
287
298
  * token was not a pending confirmation (unknown, expired, already used, or not
288
- * even token-shaped). Never touches the model.
299
+ * even token-shaped). `resume: true` (with no `text`) means the action handed
300
+ * its result back to the model: the client hears its answer by opening
301
+ * `/turn { resume: true, conversationId }`. Never touches the model.
289
302
  */
290
303
  export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDeps): Promise<Response> {
291
304
  const origin = deps.corsOrigin ?? "*";
@@ -315,6 +328,9 @@ export async function handleConfirmRequest(req: Request, deps: ConfirmRequestDep
315
328
  return Response.json({ ok: true, resolved: true, text: `Confermato ed eseguito: ${JSON.stringify(outcome.data)}` }, { headers: cors });
316
329
  case "failed":
317
330
  return Response.json({ ok: true, resolved: true, text: `Confermato, ma l'esecuzione è fallita: ${outcome.error}` }, { headers: cors });
331
+ case "resume":
332
+ // The result went back to the model: the client opens `/turn { resume: true }` to hear its answer.
333
+ return Response.json({ ok: true, resolved: true, resume: true }, { headers: cors });
318
334
  }
319
335
  }
320
336
 
package/openapi.yaml CHANGED
@@ -71,7 +71,16 @@ info:
71
71
  An irreversible action is never executed on the model's say-so: the turn
72
72
  emits a `pending` event carrying a one-time `token`. The client confirms by
73
73
  sending that token back — either as the `text` of a follow-up `POST /turn`,
74
- or via `POST /confirm`.
74
+ or via `POST /confirm`. An action can offer several `choices` (e.g. "only
75
+ this time" / "always"), each with its own token: the caller sends back one,
76
+ and the others die with it.
77
+
78
+
79
+ Some actions hand their result back to the model instead of to the caller
80
+ (a page read after the caller allowed it, for example). Confirming one by
81
+ `POST /turn` streams the model's answer on that same stream; `POST /confirm`
82
+ answers `resume: true`, and the client then opens
83
+ `POST /turn { resume: true, conversationId }` to hear it.
75
84
  security:
76
85
  - bearerAuth: []
77
86
  servers:
@@ -85,7 +94,10 @@ paths:
85
94
  Runs one model turn and streams the result as `text/event-stream`. If
86
95
  `text` is a pending confirmation token it is resolved deterministically
87
96
  without ever consulting the model. Close the connection to cancel an
88
- in-flight turn. With `opening: true` and no `text`, the assistant
97
+ in-flight turn. When the confirmation hands its result back to the
98
+ model, the model's answer to it streams instead. With `resume: true`
99
+ and no `text`, the turn is the one a `/confirm` answered `resume: true`
100
+ for. With `opening: true` and no `text`, the assistant
89
101
  speaks first in the conversation named (right after someone signs
90
102
  in): it does only for someone it doesn't know the name of, asking how
91
103
  to call them, and otherwise the stream carries a single `final` with
@@ -108,7 +120,8 @@ paths:
108
120
  An SSE stream. Each event is one of the types in `TurnEvent`:
109
121
  `reasoning`/`reasoning_end`, `text` (incremental deltas), `tool`/
110
122
  `tool_finish` (status of a tool call), `pending` (a staged
111
- confirm-required action, with its command and token), `login` (a
123
+ confirm-required action, with its command, its token and the
124
+ choices it offers), `login` (a
112
125
  service the caller has to log in to first, with the link to open),
113
126
  `final` (the
114
127
  authoritative complete answer), and `error`.
@@ -117,7 +130,7 @@ paths:
117
130
  schema:
118
131
  $ref: "#/components/schemas/TurnEvent"
119
132
  "400":
120
- description: Missing/invalid body, an opening without a conversationId, or a conversationId with characters outside the allowed ones.
133
+ description: Missing/invalid body, an opening or a resumed turn without a conversationId, or a conversationId with characters outside the allowed ones.
121
134
  content:
122
135
  application/json:
123
136
  schema:
@@ -146,7 +159,9 @@ paths:
146
159
  description: >
147
160
  `resolved: true` (with the reply `text`) when the token was a pending
148
161
  confirmation, `resolved: false` when it was not (unknown, expired, or
149
- already used).
162
+ already used). `resolved: true` with `resume: true` and no `text`
163
+ when the action handed its result back to the model: the answer
164
+ comes from `POST /turn { resume: true, conversationId }`.
150
165
  content:
151
166
  application/json:
152
167
  schema:
@@ -552,11 +567,15 @@ components:
552
567
  properties:
553
568
  text:
554
569
  type: string
555
- description: The user's message, or a bare confirmation token. Required, unless `opening` is true.
570
+ description: The user's message, or a bare confirmation token. Required, unless `opening` or `resume` is true.
556
571
  opening:
557
572
  type: boolean
558
573
  enum: [true]
559
574
  description: The assistant speaks first in `conversationId`, which is then required; `text` is ignored.
575
+ resume:
576
+ type: boolean
577
+ enum: [true]
578
+ description: The turn a `/confirm` answered `resume: true` for, in `conversationId`, which is then required; `text` is ignored.
560
579
  conversationId:
561
580
  type: string
562
581
  pattern: "^[A-Za-z0-9_-]{1,128}$"
@@ -594,7 +613,18 @@ components:
594
613
  description: On `pending` — the staged action's summary.
595
614
  token:
596
615
  type: string
597
- description: On `pending` — the one-time confirmation token.
616
+ description: On `pending` — the one-time confirmation token (the first choice's).
617
+ choices:
618
+ type: array
619
+ description: On `pending` — every way to answer, in order; a plain staging has one, labelled `Conferma`.
620
+ items:
621
+ type: object
622
+ required: [token, label]
623
+ properties:
624
+ token:
625
+ type: string
626
+ label:
627
+ type: string
598
628
  service:
599
629
  type: string
600
630
  description: On `login` — the service the caller has to log in to.
@@ -626,7 +656,11 @@ components:
626
656
  type: boolean
627
657
  text:
628
658
  type: string
629
- description: The confirmation reply, present when `resolved` is true.
659
+ description: The confirmation reply, present when `resolved` is true and `resume` isn't.
660
+ resume:
661
+ type: boolean
662
+ enum: [true]
663
+ description: The action handed its result back to the model; its answer comes from `POST /turn { resume: true, conversationId }`.
630
664
  Message:
631
665
  type: object
632
666
  required: [role, content, timestamp]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/channel-http",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,11 +18,11 @@
18
18
  "access": "public"
19
19
  },
20
20
  "peerDependencies": {
21
- "@mercury-fw/channel-types": ">=0.49.0 <1.0.0"
21
+ "@mercury-fw/channel-types": ">=0.51.0 <1.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@mercury-fw/channel-types": "0.49.0",
25
- "@mercury-fw/plugin-types": "0.49.0",
24
+ "@mercury-fw/channel-types": "0.51.0",
25
+ "@mercury-fw/plugin-types": "0.51.0",
26
26
  "@mercury-fw/typescript-config": "*",
27
27
  "@types/bun": "^1.4.2",
28
28
  "typescript": "^6.0.3"