From 02787452555b13ad304c51dc070c615b9ec17b77 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 19 May 2026 18:18:38 +0800 Subject: [PATCH 01/33] feat(amsg): three-axis push schema unification (next pre-release) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinated minor across the whole amsg ecosystem. New @rei-standard/amsg-shared package defines the AmsgPush discriminated union keyed by `messageKind` (`content` / `reasoning` / `tool_request` / `error`) as a literal-type discriminator, plus the four builders and type guards. All four existing amsg sub-packages migrate onto it. Versions (all `*-next.0` pre-release; CI publishes to `next` dist-tag): - @rei-standard/amsg-shared 0.1.0-next.0 (new) - @rei-standard/amsg-instant 0.7.0 → 0.8.0-next.0 - @rei-standard/amsg-server 2.3.2 → 2.4.0-next.0 - @rei-standard/amsg-sw 2.0.1 → 2.1.0-next.0 - @rei-standard/amsg-client 2.2.3 → 2.3.0-next.0 Inter-package deps pinned exact (no caret). Highlights: - LLM-driven paths (instant legacy + agentic-loop hook, server prompted/auto) auto-emit ReasoningPush before the content burst when the LLM response carries non-empty `reasoning_content`. Same `sessionId` across reasoning + content + every agentic-loop iteration. Hook path has an `autoEmitReasoning: false` opt-out on `createInstantHandler`. - Legacy `{ type: 'error', code: '...' }` envelope removed. HOOK_THREW / LOOP_EXCEEDED now ship as `ErrorPush` (`messageKind: 'error'`). - SW dispatches per-kind `postMessage` events to controlled clients (`rei-amsg-{content,reasoning,tool-request,error,unknown}-received`). showNotification only fires for `content` (and legacy un-kinded payloads). - Server messageId is now deterministic across retries for scheduled rows (`msg_task_{id}_{i}`); UUID fallback only when `task.id == null`. - shared package uses `tsc --allowJs --declaration --emitDeclarationOnly` for type emission (tsup's `dts:true` doesn't extract JSDoc types from .js entries) — the .d.ts now declares real discriminated-union types so TS callers can narrow on `push.messageKind`. Tests: 226/226 pass (client 6 / instant 121 / server 74 / shared 14 / sw 11). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 68 ++- package-lock.json | 36 +- .../rei-standard-amsg/client/CHANGELOG.md | 32 ++ packages/rei-standard-amsg/client/README.md | 36 ++ .../rei-standard-amsg/client/package.json | 10 +- .../rei-standard-amsg/client/src/index.js | 23 + .../client/test/exports.test.mjs | 93 ++++ .../rei-standard-amsg/instant/CHANGELOG.md | 44 +- .../examples/agentic-loop-skeleton/worker.js | 47 +- .../rei-standard-amsg/instant/package.json | 7 +- .../rei-standard-amsg/instant/src/index.js | 50 +- .../instant/src/message-processor.js | 347 ++++++++------ .../instant/test/agentic-loop.test.mjs | 9 +- .../instant/test/e2e.test.mjs | 31 +- .../instant/test/helpers.mjs | 11 +- .../instant/test/reasoning-push.test.mjs | 245 ++++++++++ .../rei-standard-amsg/server/CHANGELOG.md | 34 ++ .../rei-standard-amsg/server/package.json | 5 +- .../src/server/lib/message-processor.js | 146 ++++-- .../server/test/message-processor.test.mjs | 233 ++++++++++ .../rei-standard-amsg/shared/CHANGELOG.md | 51 ++ packages/rei-standard-amsg/shared/README.md | 253 ++++++++++ .../rei-standard-amsg/shared/package.json | 40 ++ .../rei-standard-amsg/shared/src/index.js | 440 ++++++++++++++++++ .../shared/test/builders.test.mjs | 173 +++++++ .../rei-standard-amsg/shared/tsconfig.json | 17 + .../rei-standard-amsg/shared/tsup.config.js | 20 + packages/rei-standard-amsg/sw/CHANGELOG.md | 40 ++ packages/rei-standard-amsg/sw/README.md | 56 ++- packages/rei-standard-amsg/sw/package.json | 10 +- packages/rei-standard-amsg/sw/src/index.js | 170 ++++++- .../sw/test/dispatch.test.mjs | 294 ++++++++++++ standards/active-messaging-api.md | 106 ++++- 33 files changed, 2931 insertions(+), 246 deletions(-) create mode 100644 packages/rei-standard-amsg/client/test/exports.test.mjs create mode 100644 packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs create mode 100644 packages/rei-standard-amsg/shared/CHANGELOG.md create mode 100644 packages/rei-standard-amsg/shared/README.md create mode 100644 packages/rei-standard-amsg/shared/package.json create mode 100644 packages/rei-standard-amsg/shared/src/index.js create mode 100644 packages/rei-standard-amsg/shared/test/builders.test.mjs create mode 100644 packages/rei-standard-amsg/shared/tsconfig.json create mode 100644 packages/rei-standard-amsg/shared/tsup.config.js create mode 100644 packages/rei-standard-amsg/sw/CHANGELOG.md create mode 100644 packages/rei-standard-amsg/sw/test/dispatch.test.mjs diff --git a/README.md b/README.md index 16e23de..aa99b63 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,70 @@ | 包 | 版本 | 用途 | |---|---|---| -| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.6.1` | 一次性即时推送(无 DB、无 cron、无租户) | -| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.3.2` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | -| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.2.3` | 浏览器 SDK:加密、请求封装、Push 订阅 | -| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.0.1` | Service Worker:推送展示、离线队列 | +| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | `0.1.0-next.0` | 三轴推送契约(`AmsgPush` 判别联合 + builders + 类型守卫) | +| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.8.0-next.0` | 一次性即时推送(无 DB、无 cron、无租户) | +| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.4.0-next.0` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | +| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.3.0-next.0` | 浏览器 SDK:加密、请求封装、Push 订阅 | +| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.1.0-next.0` | Service Worker:推送展示、离线队列 | + +`amsg-shared` 是依赖图最底层:其他四个包都依赖它,反过来不行;它本身零运行时依赖。 **怎么挑服务端包**:只发"按钮点了就立刻推一条" → `amsg-instant`;要定时或周期任务 → `amsg-server`;两种都要就都装,共用同一套 VAPID 与 masterKey。 +### 协调发布说明:next pre-release(shared 0.1.0-next.0 / instant 0.8.0-next.0 / sw 2.1.0-next.0 / client 2.3.0-next.0 / server 2.4.0-next.0) + +本轮是一次跨包协调的 minor 升级,统一 push wire shape 到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器)。所有 amsg 子包同时上调一个 minor,并以 `-next.0` 形式预发布。仓库的 `scripts/publish-workspaces.mjs` 自动识别 prerelease 版本号并把发布打到 `next` dist-tag(不进 `latest`)。 + +- `@rei-standard/amsg-shared` 新增 → `0.1.0-next.0` +- `@rei-standard/amsg-instant`:`0.7.0` → `0.8.0-next.0` +- `@rei-standard/amsg-server`:`2.3.2` → `2.4.0-next.0` +- `@rei-standard/amsg-sw`:`2.0.1` → `2.1.0-next.0` +- `@rei-standard/amsg-client`:`2.2.3` → `2.3.0-next.0` + +包间依赖一律使用**精确版本**(不带 `^`),所有 `dependencies` 字段都钉死在对应的 `*-next.0` 版本,避免 npm 在生态系统里解析出混版本图。同时本轮移除了旧的 `{ type: 'error', code: '...' }` 错误信封——错误推送统一走 `ErrorPush`(`messageKind: 'error'`)。 + +**安装预发布版(`next` dist-tag)**: + +```bash +npm install @rei-standard/amsg-shared@next @rei-standard/amsg-instant@next @rei-standard/amsg-server@next @rei-standard/amsg-sw@next @rei-standard/amsg-client@next +``` + +`next` 期间欢迎下游集成方反馈契约问题;契约稳定后会发对应的 `1.0` / `2.x` 正式 minor(去掉 `-next.N` 后缀,走默认 `latest` dist-tag)。 + +## 三轴推送语义(Three-axis push schema) + +每一条推送都由三个**正交**的维度描述。把"用什么方式发出去"(dispatch)、"业务命名空间"(business)、"载荷里装的是什么"(content)拆开,让一个 axis 加值的时候不需要动另外两个 axis。 + +| 轴 | 字段 | 取值 | 由谁定 | +|---|---|---|---| +| Dispatch | `messageType` | `instant` / `fixed` / `prompted` / `auto` | 包(固定枚举) | +| Business | `messageSubtype` | 任意字符串 | 调用方(自由命名) | +| Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error` | 包(固定枚举) | + +外加一个 `source: 'instant' | 'scheduled'` —— 路由来源(`instant` 来自 `amsg-instant`,`scheduled` 来自 `amsg-server` 的任何输出)。 + +**`messageKind` 四种值**(载荷里到底是什么): + +- `content` —— 最终面向用户的文本片段。携带 `message`、可选 `messageIndex` / `totalMessages`(N 段分句 burst 用)、`title`、`contactName`、`avatarUrl` 等。 +- `reasoning` —— LLM 的思考过程(`choices[0].message.reasoning_content`)。携带 `reasoningContent`。**不带** `messageIndex` / `totalMessages`,因为推理是一轮 LLM 一条,不是分句 burst。 +- `tool_request` —— Agentic loop 钩子返回的工具调用请求。携带 `toolCalls`(OpenAI `tool_calls` 透传形状),客户端执行后通过 `/continue` 恢复。 +- `error` —— 生产端诊断错误(如 `HOOK_THREW` / `LOOP_EXCEEDED`)。携带 `code`、`message`、可选 `iteration`。**取代了 0.7.x 那个 `{ type: 'error', code: '...' }` 旧信封**。 + +**`messageType` 四种值**(怎么发出来的): + +- `instant` —— 一次性即时推送(`amsg-instant` 一发即走,无 DB、无 cron)。总是配 `source: 'instant'`。 +- `fixed` —— 固定文本的定时任务(`amsg-server`,无 LLM)。 +- `prompted` —— LLM 生成 + 定时调度(`amsg-server` 的 prompted 路径)。 +- `auto` —— LLM 生成 + 自动周期(`amsg-server` 的 auto 路径)。 + +后三种 `messageType` 总是配 `source: 'scheduled'`。 + +**`messageSubtype`** 是调用方自有命名空间,框架不解读、不强约束格式(producers 默认填 `'chat'`)。业务侧爱怎么切就怎么切。 + +**`sessionId` 不变性**:同一个 LLM 轮次内自动发出的 `ReasoningPush` 和后续 `ContentPush` burst 共享同一个 `sessionId`;agentic-loop 路径下,同一个 `/instant` 请求的所有 iteration 也共享一个 `sessionId`。客户端可以靠 `sessionId` 把"思考中"UI 和真正回复拼回到同一条消息上。 + +字段表、builders(`buildContentPush` / `buildReasoningPush` / `buildToolRequestPush` / `buildErrorPush`)、类型守卫(`isContentPush` / `isReasoningPush` / …)与常量(`MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE`)的完整说明见 [`packages/rei-standard-amsg/shared/README.md`](./packages/rei-standard-amsg/shared/README.md)。 + ## 🚀 接入 1. 服务端:按你选的包打开它的 README,里面有环境变量、`createReiServer` / `createInstantHandler` 用法、各平台 (Netlify / Vercel / Cloudflare / Node) 的适配器。 @@ -34,7 +91,8 @@ npm install @rei-standard/amsg-client @rei-standard/amsg-sw ```text ReiStandard/ ├── standards/ # 权威规范文本(端点、字段、错误码) -├── packages/rei-standard-amsg/ # 4 个发布到 npm 的 SDK 包 +├── packages/rei-standard-amsg/ # 5 个发布到 npm 的 SDK 包 +│ ├── shared/ # 三轴推送契约(最底层,其他包都依赖) │ ├── server/ # 定时 / 周期消息(多租户 Blob + token) │ ├── instant/ # 一次性即时推送(无 DB / 无 cron) │ ├── client/ # 浏览器 SDK(加密、请求封装、Push 订阅) diff --git a/package-lock.json b/package-lock.json index d8bd938..729cc4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "ReiStandard", + "name": "push-schema", "lockfileVersion": 3, "requires": true, "packages": { @@ -540,6 +540,10 @@ "resolved": "packages/rei-standard-amsg/server", "link": true }, + "node_modules/@rei-standard/amsg-shared": { + "resolved": "packages/rei-standard-amsg/shared", + "link": true + }, "node_modules/@rei-standard/amsg-sw": { "resolved": "packages/rei-standard-amsg/sw", "link": true @@ -1846,8 +1850,11 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.2.3", + "version": "2.3.0-next.0", "license": "MIT", + "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0" @@ -1858,8 +1865,11 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.6.1", + "version": "0.8.0-next.0", "license": "MIT", + "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0" @@ -1870,10 +1880,11 @@ }, "packages/rei-standard-amsg/server": { "name": "@rei-standard/amsg-server", - "version": "2.3.1", + "version": "2.4.0-next.0", "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", + "@rei-standard/amsg-shared": "0.1.0-next.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -1898,10 +1909,25 @@ } } }, + "packages/rei-standard-amsg/shared": { + "name": "@rei-standard/amsg-shared", + "version": "0.1.0-next.0", + "license": "MIT", + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.0.1", + "version": "2.1.0-next.0", "license": "MIT", + "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index 4a33042..c0ee1c6 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog — @rei-standard/amsg-client +## 2.3.0-next.0 — Shared push types re-exports (pre-release) + +Published under the `next` dist-tag (repo convention for prereleases). Coordinated with the other amsg sub-packages' `*-next.0` releases. Install with `npm install @rei-standard/amsg-client@next`. Schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. + +--- + +Coordinated minor across the whole amsg ecosystem (shared 0.1.0 / instant 0.7.0 / server 2.3.2 / sw 2.x). The client itself does not send or receive pushes — it only talks to amsg-server / amsg-instant over HTTP — but caller apps that build the client and also handle pushes (typically in a Service Worker) used to need a second dependency on `@rei-standard/amsg-shared` to get the canonical kind/type/source constants, builders, and type guards. 2.3.0 collapses that into a single import surface. + +### New + +- Re-exports from `@rei-standard/amsg-shared` 0.1.0: + - **Runtime constants**: `MESSAGE_KIND` (`CONTENT` / `REASONING` / `TOOL_REQUEST` / `ERROR`), `MESSAGE_TYPE` (`INSTANT` / `FIXED` / `PROMPTED` / `AUTO`), `PUSH_SOURCE` (`INSTANT` / `SCHEDULED`). + - **Builders**: `buildContentPush`, `buildReasoningPush`, `buildToolRequestPush`, `buildErrorPush`. + - **Type guards**: `isContentPush`, `isReasoningPush`, `isToolRequestPush`, `isErrorPush`. + - **JSDoc type aliases**: `MessageKind`, `MessageType`, `PushSource`, `AmsgPush`, `ContentPush`, `ReasoningPush`, `ToolRequestPush`, `ErrorPush`. + +One import surface — caller apps that consume `ReiClient` and also handle pushes (e.g. in a Service Worker) no longer need a separate dep on `@rei-standard/amsg-shared`. Everything is reachable from `@rei-standard/amsg-client`. + +### Compatibility + +- Zero runtime behavior change. `ReiClient` API is byte-for-byte unchanged — no method signatures, request shapes, or error codes were touched. +- The re-exports are tree-shake-friendly (shared package is `sideEffects: false`). Bundlers that ship `ReiClient` only will not pull in the builders. + +### Dependencies + +- Adds `@rei-standard/amsg-shared` at exact `0.1.0` (no caret). Part of the coordinated minor; pinned so a future shared minor cannot silently slip in via `npm install` without a matching client release. + +### Migration + +- No caller-side action needed. Strictly additive. +- Apps that already depend on `@rei-standard/amsg-shared` directly can keep that dep or drop it in favor of importing from `@rei-standard/amsg-client` — both routes resolve to the same module instance because npm dedupes the exact-pinned `0.1.0`. + ## 2.2.3 — 2026-05-18 ### Fix diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index accc309..c5a03f3 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -2,6 +2,42 @@ `@rei-standard/amsg-client` 是 ReiStandard 主动消息标准的浏览器端 SDK 包,负责加密请求、解密响应和 Push 订阅。 +## v2.3.0 — Shared push types + +The client now re-exports `@rei-standard/amsg-shared` 的类型、运行时常量(`MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE`)、推送 builder(`buildContentPush` 等)和类型守卫(`isContentPush` 等)。调用方可以直接 `import { MessageKind, buildContentPush, isContentPush } from '@rei-standard/amsg-client'`,无需单独再装一个 `@rei-standard/amsg-shared` 依赖。client 本身在运行时不消费这些导出 —— 它们是给同时调 `ReiClient` 又在 Service Worker / 客户端处理推送的 app 用的便利出口。 + +```js +// app.js — 用 ReiClient 发即时消息 +import { ReiClient } from '@rei-standard/amsg-client'; + +const client = new ReiClient({ + baseUrl: 'https://instant.example.com', + instantEncryption: false, +}); +await client.sendInstant({ + contactName: 'Rei', + completePrompt: '你是 Rei,用一句话提醒用户带伞', + apiUrl: 'https://api.openai.com/v1/chat/completions', + apiKey: '...', + primaryModel: 'gpt-4o-mini', + pushSubscription: subscription.toJSON(), +}); + +// service-worker.js — 用 isContentPush 在收到推送时收窄类型 +import { isContentPush } from '@rei-standard/amsg-client'; + +self.addEventListener('push', (event) => { + const payload = event.data?.json(); + if (isContentPush(payload)) { + // payload 已被收窄为 ContentPush —— 安全读取 payload.message + event.waitUntil( + self.registration.showNotification(payload.contactName ?? 'Rei', { + body: payload.message, + }) + ); + } +}); +``` ## 安装 diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index 8b29fd6..c6653e9 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,7 +1,7 @@ { "name": "@rei-standard/amsg-client", - "version": "2.2.3", - "description": "ReiStandard Active Messaging browser client SDK", + "version": "2.3.0-next.0", + "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", "url": "https://github.com/Tosd0/ReiStandard", @@ -26,11 +26,15 @@ "dist" ], "scripts": { - "build": "tsup" + "build": "tsup", + "test": "node --test test/*.test.mjs" }, "engines": { "node": ">=20" }, + "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 2b528b7..97341c3 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -22,6 +22,15 @@ * await client.scheduleMessage({ ... }); */ +/** @typedef {import('@rei-standard/amsg-shared').MessageKind} MessageKind */ +/** @typedef {import('@rei-standard/amsg-shared').MessageType} MessageType */ +/** @typedef {import('@rei-standard/amsg-shared').PushSource} PushSource */ +/** @typedef {import('@rei-standard/amsg-shared').AmsgPush} AmsgPush */ +/** @typedef {import('@rei-standard/amsg-shared').ContentPush} ContentPush */ +/** @typedef {import('@rei-standard/amsg-shared').ReasoningPush} ReasoningPush */ +/** @typedef {import('@rei-standard/amsg-shared').ToolRequestPush} ToolRequestPush */ +/** @typedef {import('@rei-standard/amsg-shared').ErrorPush} ErrorPush */ + /** * @typedef {Object} ReiClientConfig * @property {string} baseUrl - Default base URL of the API (e.g. https://host/api/v1). @@ -506,3 +515,17 @@ export class ReiClient { return arr; } } + +export { + MESSAGE_KIND, + MESSAGE_TYPE, + PUSH_SOURCE, + buildContentPush, + buildReasoningPush, + buildToolRequestPush, + buildErrorPush, + isContentPush, + isReasoningPush, + isToolRequestPush, + isErrorPush, +} from '@rei-standard/amsg-shared'; diff --git a/packages/rei-standard-amsg/client/test/exports.test.mjs b/packages/rei-standard-amsg/client/test/exports.test.mjs new file mode 100644 index 0000000..b94a249 --- /dev/null +++ b/packages/rei-standard-amsg/client/test/exports.test.mjs @@ -0,0 +1,93 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ReiClient, + MESSAGE_KIND, + MESSAGE_TYPE, + PUSH_SOURCE, + buildContentPush, + buildReasoningPush, + buildToolRequestPush, + buildErrorPush, + isContentPush, + isReasoningPush, + isToolRequestPush, + isErrorPush, +} from '../src/index.js'; + +import { + MESSAGE_KIND as SHARED_MESSAGE_KIND, + MESSAGE_TYPE as SHARED_MESSAGE_TYPE, + PUSH_SOURCE as SHARED_PUSH_SOURCE, +} from '@rei-standard/amsg-shared'; + +test('MESSAGE_KIND re-export matches shared (CONTENT/REASONING/TOOL_REQUEST/ERROR)', () => { + assert.deepEqual(MESSAGE_KIND, SHARED_MESSAGE_KIND); + assert.equal(MESSAGE_KIND.CONTENT, 'content'); + assert.equal(MESSAGE_KIND.REASONING, 'reasoning'); + assert.equal(MESSAGE_KIND.TOOL_REQUEST, 'tool_request'); + assert.equal(MESSAGE_KIND.ERROR, 'error'); +}); + +test('MESSAGE_TYPE re-export matches shared (INSTANT/FIXED/PROMPTED/AUTO)', () => { + assert.deepEqual(MESSAGE_TYPE, SHARED_MESSAGE_TYPE); + assert.ok('INSTANT' in MESSAGE_TYPE); + assert.ok('FIXED' in MESSAGE_TYPE); + assert.ok('PROMPTED' in MESSAGE_TYPE); + assert.ok('AUTO' in MESSAGE_TYPE); +}); + +test('PUSH_SOURCE re-export matches shared (INSTANT/SCHEDULED)', () => { + assert.deepEqual(PUSH_SOURCE, SHARED_PUSH_SOURCE); + assert.ok('INSTANT' in PUSH_SOURCE); + assert.ok('SCHEDULED' in PUSH_SOURCE); +}); + +test('buildContentPush + isContentPush + isReasoningPush', () => { + const result = buildContentPush({ + messageType: 'instant', + source: 'instant', + messageId: 'm', + sessionId: 's', + message: 'hi', + }); + assert.equal(result.messageKind, 'content'); + assert.equal(isContentPush(result), true); + assert.equal(isReasoningPush(result), false); +}); + +test('all four builders + matching type guards re-export correctly', () => { + const COMMON = { + messageType: 'instant', + source: 'instant', + messageId: 'm', + sessionId: 's', + }; + const cases = [ + { build: buildContentPush, args: { ...COMMON, message: 'hi' }, kind: 'content', guard: isContentPush }, + { build: buildReasoningPush, args: { ...COMMON, reasoningContent: 'think' }, kind: 'reasoning', guard: isReasoningPush }, + { build: buildToolRequestPush, args: { ...COMMON, toolCalls: [{ id: 'c0' }] }, kind: 'tool_request', guard: isToolRequestPush }, + { build: buildErrorPush, args: { ...COMMON, code: 'X', message: 'm' }, kind: 'error', guard: isErrorPush }, + ]; + const allGuards = [isContentPush, isReasoningPush, isToolRequestPush, isErrorPush]; + + for (const c of cases) { + const push = c.build(c.args); + assert.equal(push.messageKind, c.kind); + assert.equal(c.guard(push), true); + // Each push satisfies exactly one of the four guards. + const hits = allGuards.filter((g) => g(push)).length; + assert.equal(hits, 1, `${c.kind} push should match exactly one guard`); + } +}); + +test('ReiClient constructs without throwing', () => { + assert.doesNotThrow(() => { + new ReiClient({ + baseUrl: 'https://example.com', + userId: 'u', + instantEncryption: false, + }); + }); +}); diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index aca3a88..5c0713a 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,6 +1,48 @@ # Changelog — @rei-standard/amsg-instant -## Unreleased +## 0.8.0-next.0 — Three-axis push schema + ReasoningPush (pre-release) + +Published under the `next` dist-tag (repo convention for prereleases). Coordinated with `@rei-standard/amsg-shared@0.1.0-next.0`, `amsg-server@2.4.0-next.0`, `amsg-sw@2.1.0-next.0`, `amsg-client@2.3.0-next.0`. Install with `npm install @rei-standard/amsg-instant@next`. The schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. + +--- + +Coordinated minor across the whole amsg ecosystem. This release replaces the legacy 13-field push envelope (and the standalone `{ type:'error', code:'...' }` shape) with a discriminated union from the new `@rei-standard/amsg-shared` package, indexed by `messageKind`. It also lifts LLM `reasoning_content` into its own first-class push so clients can render "thinking…" UI ahead of the actual reply. + +### Breaking + +- **Push wire shape now follows `@rei-standard/amsg-shared`'s `AmsgPush` union.** Every push carries `messageKind: 'content' | 'reasoning' | 'tool_request' | 'error'` as a literal-type discriminator. TS callers `switch (push.messageKind)` and narrow on it. +- **The 0.7.x `{ type: 'error', code: '...' }` diagnostic envelope (used for `HOOK_THREW` and `LOOP_EXCEEDED`) is gone.** Diagnostics are now `ErrorPush` (`messageKind: 'error'` + same `code` / `message` fields). The legacy `type: 'error'` field is **not** present on the new envelope — do not look for it. +- **Public export `buildInstantPushPayload` removed.** Use `buildContentPush` from `@rei-standard/amsg-shared` (re-exported from this package). The new builder takes the three-axis fields (`messageType` / `source` / `messageKind`) + the legacy 13 fields as optionals. + +### New + +- **Auto-emit `ReasoningPush` before the content burst / hook.** When the LLM response carries a non-empty `choices[0].message.reasoning_content`, the framework now ships a separate `ReasoningPush` first, then the existing content path. Both the legacy sentence-split path AND the agentic-loop hook path do this. +- **`autoEmitReasoning` config (default `true`)** — hook-path opt-out. Set to `false` on `createInstantHandler({...})` when the hook author wants total control over every push that leaves the worker. In that mode, hooks can read `ctx.llmResponse.choices[0].message.reasoning_content` and build their own `buildReasoningPush(...)` envelope. The legacy (non-hook) path always auto-emits regardless — it has no hook control point to honor. +- **`sessionId` is stable across one LLM round.** The auto-emitted ReasoningPush and the content burst that follows it share the same `sessionId`. In the agentic-loop path, all iterations of a single `/instant` request also share one `sessionId`. Legacy path: mints `sess_` if the payload didn't carry one. Hook path: reuses `payload.sessionId` or mints a UUID. **The hook is responsible for propagating `ctx.sessionId` into its own `pushPayload`** — the framework does not inject it. +- **Blob envelope now carries `messageKind`.** When a push exceeds `maxInlineBytes`, the `{ _blob, key, url }` envelope now also includes `messageKind` (and the legacy `type` field for hand-rolled hook payloads). The SW can dispatch on the discriminator without having to fetch the blob first. +- **Builder / type guard re-exports.** `buildContentPush`, `buildReasoningPush`, `buildToolRequestPush`, `buildErrorPush`, `isContentPush`, `isReasoningPush`, `isToolRequestPush`, `isErrorPush`, `MESSAGE_KIND`, `MESSAGE_TYPE`, `PUSH_SOURCE` are all re-exported from `@rei-standard/amsg-instant` so hook authors don't need a second dependency on `@rei-standard/amsg-shared`. +- **`readReasoningContent(llmResponse)` helper** exported for hook authors who need to inspect or post-process reasoning content before deciding what to push. +- **New event types**: `reasoning_pushed`, `reasoning_push_failed`. Both carry `sessionId` and (for the hook path) `iteration`. + +### Migration from 0.7.x + +| 0.7.x | 0.8.0 | +|------------------------------------------------------------------------|------------------------------------------------------------------------------------------| +| `buildInstantPushPayload({ message, index, total, contactName, ... })` | `buildContentPush({ messageType: 'instant', source: 'instant', messageId, sessionId, message, messageIndex, totalMessages, contactName, ... })` from `@rei-standard/amsg-instant` | +| Hook payload `{ type: 'tool-request', ... }` (free-form) | Either keep it free-form (still legal — `pushPayload: unknown`) or call `buildToolRequestPush({ ... })` for a typed envelope | +| SW dispatch by ad-hoc field sniffing on push payload | SW dispatch by `payload.messageKind` switch (consume the shared `AmsgPush` discriminated union) | +| `{ type: 'error', code: 'HOOK_THREW', message, sessionId, iteration }` | Auto-built — no caller-side change needed; the wire shape now uses `messageKind: 'error'` instead of `type: 'error'` | +| Hook fully owned every push (incl. reasoning, if you built one) | Framework auto-emits `ReasoningPush` before the hook runs. Set `autoEmitReasoning: false` on `createInstantHandler({...})` to restore total hook control. | +| Hook returned `pushPayload` without a `sessionId` field | **Set `sessionId: ctx.sessionId`** in your hook's `pushPayload`. The framework does NOT auto-inject it (the `pushPayload: unknown` contract is preserved). Without this the SW can't pair your content push with the auto-emitted ReasoningPush. | +| Legacy path push failure aborted the whole burst | Reasoning-push failure is now best-effort (`reasoning_push_failed` event + continue). Content-push failures still abort, same as before. | + +If you have a hook that builds its own pushPayload object, **set `sessionId: ctx.sessionId`** in it so the SW can pair your content push with the auto-emitted ReasoningPush. + +### Dependencies + +- Adds `@rei-standard/amsg-shared` at exact version `0.1.0` (no caret). The coordinated minor upgrade is intentionally strict — npm shouldn't resolve a mixed-version graph across the ecosystem. + +## Unreleased (pre-0.8.0) **Fix** diff --git a/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js b/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js index ee95d71..3cb68f2 100644 --- a/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js +++ b/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js @@ -16,7 +16,11 @@ * own `worker.js` — not imported as a library. */ -import { createInstantHandler, buildInstantPushPayload } from '@rei-standard/amsg-instant'; +import { + createInstantHandler, + buildContentPush, + buildToolRequestPush, +} from '@rei-standard/amsg-instant'; import { createMemoryBlobStore } from '@rei-standard/amsg-instant/blob/memory'; export default { @@ -49,15 +53,21 @@ async function onLLMOutput(ctx) { if (text.includes('NEED_TOOL')) { return { decision: 'tool-request', - pushPayload: { - type: 'tool-request', + pushPayload: buildToolRequestPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${crypto.randomUUID()}_tool`, sessionId: ctx.sessionId, - iteration: ctx.iteration, - // Pass enough state for the SW to re-POST /continue - messages: ctx.messages, - tool: parseToolName(text), - // Anything else the SW needs — keep it JSON-safe. - }, + // OpenAI-compatible tool_calls passthrough — replace with your + // own protocol shape (custom marker / XML / NL classification) + // if you don't speak OpenAI tool-call JSON. + toolCalls: [ + { id: 'call_0', type: 'function', function: { name: parseToolName(text), arguments: '{}' } }, + ], + contactName: ctx.contactName, + // SW can include arbitrary client routing state in metadata. + metadata: { iteration: ctx.iteration, messages: ctx.messages }, + }), }; } @@ -81,17 +91,24 @@ async function onLLMOutput(ctx) { // ─── (C) Plain answer: deliver and finish ────────────────────────── // - // The 13-field v0.6 default push payload still works fine — call - // `buildInstantPushPayload` if you want it. Or build your own; the - // SW is yours. + // The hook returns a ContentPush (from @rei-standard/amsg-shared) so + // the SW can dispatch on `messageKind === 'content'`. Build your own + // free-form pushPayload object if you don't want shared types — the + // hook contract is `pushPayload: unknown`. return { decision: 'finish', - pushPayload: buildInstantPushPayload({ + pushPayload: buildContentPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${crypto.randomUUID()}_content_0`, + sessionId: ctx.sessionId, message: text, - index: 0, - total: 1, + title: `来自 ${ctx.contactName}`, contactName: ctx.contactName, avatarUrl: ctx.avatarUrl ?? null, + messageIndex: 1, + totalMessages: 1, + taskId: null, }), }; } diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 5032828..f7ffd33 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,7 +1,7 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.7.0", - "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Zero runtime deps, pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags. Fully back-compat with v0.6 one-shot mode.", + "version": "0.8.0-next.0", + "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", "url": "https://github.com/Tosd0/ReiStandard", @@ -83,6 +83,9 @@ "engines": { "node": ">=18" }, + "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index a4c8fbb..5694d7c 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -71,6 +71,18 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * instead. Without `blobStore` the over-sized payload * throws `PayloadTooLargeError`. * @property {number} [maxLoopIterations=10] - Hard ceiling on in-loop `decision:'continue'` rounds within a single worker invocation. Cross-invocation `/continue` floods are the deployer's auth/rate-limit concern. + * @property {boolean} [autoEmitReasoning=true] + * - **v0.8 hook-path config.** When `true` (default), the + * framework auto-emits a `ReasoningPush` before invoking + * `onLLMOutput` whenever the LLM response carries a + * non-empty `choices[0].message.reasoning_content`. The + * hook can still `skip-push` its own content/tool push — + * the reasoning push has already shipped. Set to `false` + * when the hook author wants total control over every + * push that leaves the worker; in that mode the hook can + * read `ctx.llmResponse.choices[0].message.reasoning_content` + * and produce its own `buildReasoningPush(...)` envelope. + * Legacy (non-hook) path always auto-emits regardless. */ /** @@ -106,6 +118,10 @@ export function createInstantHandler(options) { const maxLoopIterations = Number.isInteger(options.maxLoopIterations) && options.maxLoopIterations > 0 ? options.maxLoopIterations : 10; + // Default true: reasoning emission "just works" out of the box for + // most hook callers. The legacy path ignores this setting and + // always auto-emits. + const autoEmitReasoning = options.autoEmitReasoning !== false; // One-shot startup warning: a caller who sets both `onLLMOutput` // and `splitPattern` almost certainly hasn't realised the hook @@ -252,6 +268,7 @@ export function createInstantHandler(options) { onLLMOutput, blobStore, maxLoopIterations, + autoEmitReasoning, requestUrl: request.url, isResume: isContinue, }); @@ -260,13 +277,13 @@ export function createInstantHandler(options) { onEvent({ type: 'error', code: err?.code, message: err?.message }); const code = err?.code || 'INTERNAL_ERROR'; const status = mapErrorStatus(err, code); - // Unified envelope: every error goes through `error: { code, message }` - // so SDK consumers can always read `body.error.code`. The plan's - // earlier draft had HOOK_THREW emit a flat `error: 'hook_threw'` - // string — that diverged from every other v0.6/v0.7 error and made - // `body.error.code` undefined for hook failures. The push-payload - // wire format (what the SW receives) stays as `{type:'error', - // code:'HOOK_THREW',...}` — that's a separate layer. + // Unified HTTP envelope: every error goes through + // `error: { code, message }` so SDK consumers can always read + // `body.error.code`. The push-payload wire format (what the SW + // receives) is a separate layer — since 0.8.0 it's an ErrorPush + // (`messageKind: 'error'`, `code`, `message`, `iteration?`) built + // via `buildErrorPush(...)`. The pre-0.8.0 `{type:'error', code, + // ...}` envelope is gone — do not look for the `type` field. return respond(status, { success: false, error: { code, message: err?.message || '内部错误' }, @@ -508,9 +525,26 @@ export { splitMessageIntoSentences, processInstantMessage, normalizeAiApiUrl, - buildInstantPushPayload, sendPushWithMaybeBlob, + readReasoningContent, } from './message-processor.js'; export { sendWebPush, buildVapidJwt, verifyVapidJwt } from './webpush.js'; export { HookError, PayloadTooLargeError, LlmCallError, MemoryStoreFullError } from './errors.js'; export { buildSessionContext, extractAssistantMessage } from './session-context.js'; + +// Re-export the shared push schema so hook authors can import builders +// and types from a single place (instant) rather than having to add a +// second dependency on @rei-standard/amsg-shared. +export { + MESSAGE_KIND, + MESSAGE_TYPE, + PUSH_SOURCE, + buildContentPush, + buildReasoningPush, + buildToolRequestPush, + buildErrorPush, + isContentPush, + isReasoningPush, + isToolRequestPush, + isErrorPush, +} from '@rei-standard/amsg-shared'; diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 6a4cabd..e887d98 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -3,16 +3,24 @@ * ReiStandard amsg-instant * * Lifecycle of a single instant request: - * call LLM (OpenAI-compatible) → split into sentences → - * send each sentence as its own Web Push notification (1500ms spacing) → - * return success. + * call LLM (OpenAI-compatible) → + * [if reasoning_content present] emit a ReasoningPush → + * split into sentences → send each sentence as its own ContentPush + * (1500ms spacing) → return success. * - * Push payload field shape MUST stay identical to - * `server/src/server/lib/message-processor.js:78-93` so the same SW - * (`@rei-standard/amsg-sw`) handles both scheduled and instant pushes - * uniformly via the `source` discriminator. + * Push wire shape comes from `@rei-standard/amsg-shared`'s discriminated + * union (`AmsgPush`). The same `messageKind` switch is consumed by + * `@rei-standard/amsg-sw` regardless of whether the push originated + * here (`source: 'instant'`) or in `@rei-standard/amsg-server` + * (`source: 'scheduled'`). */ +import { + buildContentPush, + buildReasoningPush, + buildErrorPush, +} from '@rei-standard/amsg-shared'; + import { sendWebPush } from './webpush.js'; import { randomUUID } from './utils.js'; import { HookError, LlmCallError, PayloadTooLargeError } from './errors.js'; @@ -46,7 +54,7 @@ function splitOnceByRegex(chunk, regex) { /** * Split a message into individual sentences for sequential delivery. - * Mirrors amsg-server message-processor.js:59-70 (do not drift). + * Mirrors amsg-server message-processor.js (do not drift). * * `splitPattern` is an optional caller-provided override: * - `string` → single regex source, used in place of the default @@ -84,50 +92,6 @@ export function splitMessageIntoSentences(messageContent, splitPattern = null) { return chunks.length > 0 ? chunks : [messageContent]; } -/** - * Build the SW-facing JSON payload for a single sentence in an instant - * burst. Exported so test suites can verify the wire shape without having - * to decrypt RFC 8291 ciphertext. - * - * Field-for-field parity with `amsg-server/src/server/lib/message-processor.js:78-93` - * is the contract — drift here will break the shared SW. - * - * @param {Object} args - * @param {string} args.message - * @param {number} args.index - 0-based. - * @param {number} args.total - * @param {string} args.contactName - * @param {string|null} [args.avatarUrl] - * @param {string} [args.messageSubtype='chat'] - * @param {Object} [args.metadata={}] - * @returns {Object} - */ -export function buildInstantPushPayload({ - message, - index, - total, - contactName, - avatarUrl = null, - messageSubtype = 'chat', - metadata = {}, -}) { - return { - title: `来自 ${contactName}`, - message, - contactName, - messageId: `msg_${randomUUID()}_instant_${index}`, - messageIndex: index + 1, - totalMessages: total, - messageType: 'instant', - messageSubtype, - taskId: null, - timestamp: new Date().toISOString(), - source: 'instant', - avatarUrl, - metadata, - }; -} - /** * Normalize the AI API URL for OpenAI-compatible chat endpoints. * @@ -222,17 +186,10 @@ function buildAiRequestBody(payload) { return body; } -async function callLlm(payload, fetchImpl) { - const { content } = await callLlmRaw(payload, fetchImpl, /*requireContent=*/true); - return content.trim(); -} - /** - * Raw LLM call shared by the legacy path and the v0.7 hook loop. The - * legacy path uses {@link callLlm} which trims the content string; - * the hook loop calls this directly so it can append the full - * `choices[0].message` object (preserving `tool_calls` / - * `reasoning_content`) to its rolling history. + * Raw LLM call. Returns the full response object so callers can read + * `choices[0].message.reasoning_content` and `tool_calls` along with + * the content string. * * When `requireContent` is true (legacy path), a missing / * empty-string `choices[0].message.content` is a hard error — that @@ -282,41 +239,61 @@ async function callLlmRaw(payload, fetchImpl, requireContent) { }; } +/** + * Read `choices[0].message.reasoning_content` as a non-empty trimmed + * string, or null when absent / empty. Many providers return an + * empty string instead of omitting the field — treat that the same + * as missing so we don't emit an empty ReasoningPush. + * + * @param {unknown} llmResponse + * @returns {string | null} + */ +function readReasoningContent(llmResponse) { + if (!llmResponse || typeof llmResponse !== 'object') return null; + const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices; + if (!Array.isArray(choices) || choices.length === 0) return null; + const message = /** @type {{ message?: { reasoning_content?: unknown } }} */ (choices[0])?.message; + const raw = message?.reasoning_content; + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + /** * Process one instant request. Dispatches between two **independent** * paths based on whether the caller provided an `onLLMOutput` hook: * - * - No hook + not a `/continue` resume → **legacy v0.6 path** - * (`runLegacyInstant`): byte-for-byte equivalent to v0.6 - * — single LLM call, sentence-split, sequential pushes with - * 1500 ms spacing. `splitPattern`, `messageSubtype`, - * `buildInstantPushPayload`'s 13 fields all preserved. - * - * - Hook provided (or `isResume === true`) → **v0.7 agentic loop** - * (`runAgenticLoop`): per-turn LLM call, hand `SessionContext` to - * the hook, dispatch on `decision` (finish / tool-request / - * continue / skip-push). Blob envelope kicks in when the hook's - * pushPayload exceeds `blobStore.maxInlineBytes`. + * - No hook + not a `/continue` resume → **legacy path** + * (`runLegacyInstant`): single LLM call, sentence-split, sequential + * pushes with 1500 ms spacing. v0.8 emits an additional + * ReasoningPush before the content burst when the LLM response + * includes a non-empty `reasoning_content`. * - * The two paths intentionally do NOT share schema: hooked callers - * speak in custom pushPayload objects, legacy callers speak in - * sentence-split bursts of 13-field default payloads. Trying to - * unify the two would force one of them into the other's contract. + * - Hook provided (or `isResume === true`) → **agentic loop** + * (`runAgenticLoop`): per-turn LLM call. v0.8 emits a + * ReasoningPush BEFORE invoking the hook when the LLM response + * includes `reasoning_content` (skippable via + * `autoEmitReasoning: false`). The hook then decides via the + * same 4-decision contract; the hook's `pushPayload` is what + * `sw` will route as the kind-specific content push. * - * @param {Object} payload - Validated request body. For legacy: - * identical to v0.6. For hook path: same plus `sessionId`, - * `iteration?`, and `messages` (not `completePrompt`). + * @param {Object} payload - Validated request body. * @param {Object} ctx * @param {{ email: string, publicKey: string, privateKey: string }} ctx.vapid - * @param {Function} [ctx.fetch] - fetch impl (globalThis.fetch). Both LLM and push share it. - * @param {Function} [ctx.sleep] - sleep impl (testability, legacy path only). + * @param {Function} [ctx.fetch] + * @param {Function} [ctx.sleep] * @param {(e: object) => void} [ctx.onEvent] * @param {(c: import('./session-context.js').SessionContext) => * Promise | object} [ctx.onLLMOutput] * @param {import('./blob-store/interface.js').BlobStoreConfig} [ctx.blobStore] * @param {number} [ctx.maxLoopIterations] - * @param {string} [ctx.requestUrl] - Inbound `request.url`; used to derive blob envelope URLs. - * @param {boolean} [ctx.isResume] - True when entered via `/continue`. + * @param {string} [ctx.requestUrl] + * @param {boolean} [ctx.isResume] + * @param {boolean} [ctx.autoEmitReasoning=true] - Hook path only. When + * `false`, the framework will not auto-emit ReasoningPush before + * invoking the hook — callers wanting reasoning emission must build + * it themselves with `buildReasoningPush` and push it via their own + * `pushPayload`. * @returns {Promise} */ export async function processInstantMessage(payload, ctx) { @@ -327,56 +304,118 @@ export async function processInstantMessage(payload, ctx) { } /** - * v0.6 legacy path — extracted verbatim so the hook-path branch - * cannot disturb its semantics. Byte-for-byte identical output to - * v0.6: sentence split, sequential push with 1500 ms spacing, - * 13-field default push payload. + * Legacy path — single LLM call, sentence-split, sequential push. + * v0.8: emits a ReasoningPush before the content burst when + * `reasoning_content` is present in the LLM response. * * @param {Object} payload * @param {Object} ctx - * @returns {Promise<{ messagesSent: number, sentAt: string }>} + * @returns {Promise<{ messagesSent: number, sentAt: string, sessionId: string }>} */ async function runLegacyInstant(payload, ctx) { const fetchImpl = ctx.fetch || globalThis.fetch; const sleep = ctx.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; + // sessionId is shared across all pushes from this legacy invocation: + // an optional ReasoningPush + N ContentPush sentences. Callers can + // pass `sessionId` to pin it across retries; otherwise mint one. + const sessionId = typeof payload.sessionId === 'string' && payload.sessionId + ? payload.sessionId + : `sess_${randomUUID()}`; + + let llmResponse; let messageContent; try { - messageContent = await callLlm(payload, fetchImpl); - onEvent({ type: 'llm_done' }); + const { response, content } = await callLlmRaw(payload, fetchImpl, /*requireContent=*/true); + llmResponse = response; + messageContent = content.trim(); + onEvent({ type: 'llm_done', sessionId }); } catch (err) { const error = new Error(err?.message || 'LLM call failed'); error.code = 'LLM_CALL_FAILED'; throw error; } - const messages = splitMessageIntoSentences(messageContent, payload.splitPattern ?? null); const pushSubscription = payload.pushSubscription; const contactName = payload.contactName; const avatarUrl = payload.avatarUrl || null; const messageSubtype = payload.messageSubtype || 'chat'; const metadata = payload.metadata || {}; + // Step 1: ReasoningPush if reasoning_content present. Emitted before + // the content burst so clients can render a "thinking…" UI ahead of + // the actual reply. + const reasoning = readReasoningContent(llmResponse); + if (reasoning) { + const reasoningPush = buildReasoningPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${randomUUID()}_instant_reasoning`, + sessionId, + reasoningContent: reasoning, + timestamp: new Date().toISOString(), + title: `来自 ${contactName}`, + contactName, + avatarUrl, + messageSubtype, + metadata, + }); + + // Best-effort: a failed reasoning push must NOT eclipse the + // user-facing content burst. Mirrors the hook path's + // `reasoning_push_failed` event (runAgenticLoop). + let reasoningShipped = false; + try { + await sendWebPush({ + subscription: pushSubscription, + payload: JSON.stringify(reasoningPush), + vapid: ctx.vapid, + fetch: fetchImpl, + }); + reasoningShipped = true; + onEvent({ type: 'reasoning_pushed', sessionId }); + } catch (err) { + onEvent({ type: 'reasoning_push_failed', sessionId, cause: err }); + } + + // Only space the burst when the reasoning push actually shipped — + // skipping the sleep when it failed shaves 1.5s off the perceived + // latency for that case. + if (reasoningShipped) { + await sleep(SLEEP_BETWEEN_MESSAGES_MS); + } + } + + // Step 2: ContentPush burst. + const messages = splitMessageIntoSentences(messageContent, payload.splitPattern ?? null); + for (let i = 0; i < messages.length; i++) { - const notificationPayload = buildInstantPushPayload({ + const contentPush = buildContentPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${randomUUID()}_instant_${i}`, + sessionId, message: messages[i], - index: i, - total: messages.length, + timestamp: new Date().toISOString(), + title: `来自 ${contactName}`, contactName, avatarUrl, messageSubtype, + messageIndex: i + 1, + totalMessages: messages.length, + taskId: null, metadata, }); try { await sendWebPush({ subscription: pushSubscription, - payload: JSON.stringify(notificationPayload), + payload: JSON.stringify(contentPush), vapid: ctx.vapid, fetch: fetchImpl, }); - onEvent({ type: 'push_sent', messageIndex: i + 1, totalMessages: messages.length }); + onEvent({ type: 'push_sent', messageIndex: i + 1, totalMessages: messages.length, sessionId }); } catch (err) { const error = new Error(err?.message || 'Web Push delivery failed'); error.code = 'PUSH_SEND_FAILED'; @@ -392,20 +431,21 @@ async function runLegacyInstant(payload, ctx) { return { messagesSent: messages.length, - sentAt: new Date().toISOString() + sentAt: new Date().toISOString(), + sessionId, }; } /** - * v0.7 agentic-loop path. Repeats: - * call LLM → build SessionContext → onLLMOutput(ctx) → dispatch. + * Agentic-loop path. Repeats: + * call LLM → [auto-emit ReasoningPush if configured] → buildSessionContext → + * onLLMOutput(ctx) → dispatch. * * Hard cap at `maxLoopIterations` (default 10): once exceeded, the - * worker emits `loop_exceeded`, pushes a diagnostic envelope to the - * SW, and returns HTTP 200 with `{ status: 'loop_exceeded', ... }`. + * worker emits `loop_exceeded`, pushes an ErrorPush diagnostic, and + * returns HTTP 200 with `{ status: 'loop_exceeded', ... }`. * Loop-exceeded is NOT thrown — the worker has completed its - * "deliver a diagnostic" contract, and a non-2xx would make clients - * mis-treat it as retryable. + * "deliver a diagnostic" contract. * * @param {Object} payload * @param {Object} ctx @@ -420,6 +460,10 @@ async function runAgenticLoop(payload, ctx) { const sessionId = typeof payload.sessionId === 'string' && payload.sessionId ? payload.sessionId : randomUUID(); + // Default true: most hook callers want reasoning emission to "just + // work". Set false when the hook caller wants total control over + // every push that leaves the worker. + const autoEmitReasoning = ctx.autoEmitReasoning !== false; if (ctx.isResume) { onEvent({ type: 'continue_received', sessionId, iteration: payload.iteration ?? 0 }); @@ -445,6 +489,39 @@ async function runAgenticLoop(payload, ctx) { const assistantMessage = extractAssistantMessage(llmResponse); messages = [...messages, assistantMessage]; + // Auto-emit ReasoningPush BEFORE the hook so the hook can still + // `skip-push` its own content push without losing the reasoning + // signal. Best-effort: if the auto-push throws, the loop turns it + // into a `reasoning_push_failed` event and continues — never let + // an instrumentation push eclipse the user-facing path. + if (autoEmitReasoning) { + const reasoning = readReasoningContent(llmResponse); + if (reasoning) { + const reasoningPush = buildReasoningPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${randomUUID()}_iter_${iteration}_reasoning`, + sessionId, + reasoningContent: reasoning, + timestamp: new Date().toISOString(), + title: payload.contactName ? `来自 ${payload.contactName}` : undefined, + contactName: payload.contactName, + avatarUrl: payload.avatarUrl || null, + messageSubtype: payload.messageSubtype || 'chat', + metadata: payload.metadata || {}, + }); + try { + await sendPushWithMaybeBlob(reasoningPush, payload, ctx, sessionId); + onEvent({ type: 'reasoning_pushed', sessionId, iteration }); + } catch (err) { + // Don't fail the whole turn for a reasoning instrumentation + // push — log it and continue. The user-facing content/tool + // path is still going to run. + onEvent({ type: 'reasoning_push_failed', sessionId, iteration, cause: err }); + } + } + } + const sessionCtx = buildSessionContext({ sessionId, messages, @@ -461,17 +538,17 @@ async function runAgenticLoop(payload, ctx) { decision = await ctx.onLLMOutput(sessionCtx); assertValidDecision(decision); } catch (err) { - // Hook contract violation: emit event, try to push a diagnostic, - // then throw HookError. The diagnostic push is best-effort — - // its failure must not eclipse the original hook error. onEvent({ type: 'hook_threw', sessionId, iteration, cause: err }); - const diagnostic = { - type: 'error', - code: 'HOOK_THREW', + const diagnostic = buildErrorPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${randomUUID()}_iter_${iteration}_error`, sessionId, - iteration, + code: 'HOOK_THREW', message: err?.message ?? 'onLLMOutput hook threw', - }; + iteration, + timestamp: new Date().toISOString(), + }); try { await sendWebPush({ subscription: payload.pushSubscription, @@ -511,13 +588,16 @@ async function runAgenticLoop(payload, ctx) { // Loop budget exhausted: emit, attempt diagnostic push, return 200. onEvent({ type: 'loop_exceeded', sessionId, iteration }); - const diagnostic = { - type: 'error', - code: 'LOOP_EXCEEDED', + const diagnostic = buildErrorPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${randomUUID()}_loop_exceeded`, sessionId, - iteration, + code: 'LOOP_EXCEEDED', message: `Agentic loop exceeded ${maxLoopIterations} iterations`, - }; + iteration, + timestamp: new Date().toISOString(), + }); try { await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); } catch (err) { @@ -561,13 +641,18 @@ function stringifyForError(value) { } /** - * Push the hook-provided `pushPayload`. If its UTF-8 byte length - * exceeds `maxInlineBytes`: + * Push a payload (any of the four `messageKind` types or a free-form + * hook payload). If its UTF-8 byte length exceeds `maxInlineBytes`: * - With a `blobStore` configured → write body to the store, push - * a small envelope `{ _blob:true, key, url, type? }` instead. + * a small envelope `{ _blob:true, key, url, messageKind?, type? }` + * instead. * - Without → emit `payload_too_large` and throw * `PayloadTooLargeError`. * + * The envelope's `messageKind` (and legacy `type`) field is lifted + * from the original payload when present, so the SW can dispatch on + * the discriminator without having to fetch the blob first. + * * The byte check uses **UTF-8 bytes**, not JS string `.length`. * String `.length` counts UTF-16 code units; a Chinese character is * `.length === 1` but takes 3 bytes in UTF-8, and using `.length` @@ -632,19 +717,17 @@ async function sendPushWithMaybeBlob(pushPayload, payload, ctx, sessionId) { } onEvent({ type: 'blob_written', key, size: byteLen, sessionId }); - // Build absolute envelope URL from the inbound request — SW fetches - // back to the same origin without needing a separate endpoint - // config. Hard-coded `/blob/...` path: deployers wanting a sub- - // prefix must strip it in their outer router (see README §Subpath - // mount). const blobUrl = buildBlobUrl(ctx.requestUrl, key); + // Lift `messageKind` (and legacy `type`) into the envelope so the + // SW can dispatch on the discriminator without having to fetch the + // blob body first. + const payloadObj = pushPayload && typeof pushPayload === 'object' ? pushPayload : {}; const envelope = { _blob: true, key, url: blobUrl, - type: pushPayload && typeof pushPayload === 'object' - ? /** @type {{ type?: unknown }} */ (pushPayload).type - : undefined, + messageKind: /** @type {{ messageKind?: unknown }} */ (payloadObj).messageKind, + type: /** @type {{ type?: unknown }} */ (payloadObj).type, }; try { @@ -661,11 +744,7 @@ async function sendPushWithMaybeBlob(pushPayload, payload, ctx, sessionId) { } /** - * Derive the absolute `/blob/:key` URL the SW should fetch. Uses the - * inbound `request.url` so the package never has to know the public - * hostname. Falls back to a root-anchored path when `requestUrl` is - * absent (rare — only when the handler is invoked outside the HTTP - * adapter, e.g. via unit-test harness). + * Derive the absolute `/blob/:key` URL the SW should fetch. * * @param {string | undefined} requestUrl * @param {string} key @@ -682,4 +761,4 @@ function buildBlobUrl(requestUrl, key) { return `/blob/${key}`; } -export { sendPushWithMaybeBlob }; +export { sendPushWithMaybeBlob, readReasoningContent }; diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 754622f..08635c6 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -216,8 +216,10 @@ describe('agentic loop — loop-exceeded', () => { assert.equal(router.pushCalls.length, 1); const decrypted = await decryptCapturedPushBody(router.pushCalls[0].body, subKit); const decoded = JSON.parse(decrypted); - assert.equal(decoded.type, 'error'); + assert.equal(decoded.messageKind, 'error'); assert.equal(decoded.code, 'LOOP_EXCEEDED'); + // Legacy {type:'error'} envelope is gone in 0.8.0. + assert.equal('type' in decoded, false); }); }); @@ -243,7 +245,10 @@ describe('agentic loop — hook contract violations', () => { assert.ok(events.find((e) => e.type === 'hook_threw')); assert.equal(router.pushCalls.length, 1); const decrypted = await decryptCapturedPushBody(router.pushCalls[0].body, subKit); - assert.equal(JSON.parse(decrypted).code, 'HOOK_THREW'); + const decoded = JSON.parse(decrypted); + assert.equal(decoded.code, 'HOOK_THREW'); + assert.equal(decoded.messageKind, 'error'); + assert.equal('type' in decoded, false); }); it('hook returns null → HookError path', async () => { diff --git a/packages/rei-standard-amsg/instant/test/e2e.test.mjs b/packages/rei-standard-amsg/instant/test/e2e.test.mjs index 8f42680..9437d94 100644 --- a/packages/rei-standard-amsg/instant/test/e2e.test.mjs +++ b/packages/rei-standard-amsg/instant/test/e2e.test.mjs @@ -1,12 +1,16 @@ /** - * E2E test for amsg-instant 0.3.0. + * E2E test for amsg-instant 0.8.0. * - * Verifies the push payload field shape produced by amsg-instant remains - * byte-identical to amsg-server's scheduled path, so the shared SW - * (`@rei-standard/amsg-sw`) keeps working unchanged. We intercept the - * outgoing Web Push HTTP request via `options.fetch`, then decrypt the - * RFC 8291 ciphertext using the test subscription's private key to read - * the JSON the SW would actually receive. + * Verifies the push payload field shape produced by amsg-instant + * matches the three-axis schema from @rei-standard/amsg-shared: + * - messageKind: 'content' (with messageIndex / totalMessages set) + * - all 13 legacy 0.7.x fields still present (back-compat for the + * downstream SullyOS SW, which already grew handlers for them) + * - plus new fields: messageKind, sessionId + * + * We intercept the outgoing Web Push HTTP request via `options.fetch`, + * then decrypt the RFC 8291 ciphertext using the test subscription's + * private key to read the JSON the SW would actually receive. */ import { describe, it, before } from 'node:test'; @@ -32,7 +36,7 @@ before(async () => { }); describe('e2e: push payload contract parity with amsg-server', () => { - it('produces a payload with every field defined in message-processor.js:78-93', async () => { + it('produces a ContentPush carrying all 13 legacy fields + messageKind + sessionId', async () => { const payload = { contactName: '小手机', avatarUrl: 'https://example.com/avatar.png', @@ -68,6 +72,7 @@ describe('e2e: push payload contract parity with amsg-server', () => { } const required = [ + // legacy 0.7.x fields (still present in 0.8.0 ContentPush) 'title', 'message', 'contactName', @@ -81,11 +86,15 @@ describe('e2e: push payload contract parity with amsg-server', () => { 'source', 'avatarUrl', 'metadata', + // 0.8.0 additions + 'messageKind', + 'sessionId', ]; for (const field of required) { assert.ok(field in captured[0], `payload missing field: ${field}`); } + assert.equal(captured[0].messageKind, 'content'); assert.equal(captured[0].title, '来自 小手机'); assert.equal(captured[0].message, '第一句。'); assert.equal(captured[0].messageType, 'instant'); @@ -97,9 +106,15 @@ describe('e2e: push payload contract parity with amsg-server', () => { assert.match(captured[0].messageId, /^msg_[0-9a-f-]+_instant_0$/); assert.equal(captured[0].totalMessages, 2); assert.equal(captured[0].messageIndex, 1); + assert.match(captured[0].sessionId, /^sess_/); + assert.equal(captured[1].messageKind, 'content'); assert.equal(captured[1].message, '第二句!'); assert.equal(captured[1].messageIndex, 2); assert.match(captured[1].messageId, /^msg_[0-9a-f-]+_instant_1$/); + + // Both pushes share the SAME sessionId — that's the new + // "one LLM round → one sessionId" invariant from the shared schema. + assert.equal(captured[0].sessionId, captured[1].sessionId); }); }); diff --git a/packages/rei-standard-amsg/instant/test/helpers.mjs b/packages/rei-standard-amsg/instant/test/helpers.mjs index 2ae35b2..db53a3d 100644 --- a/packages/rei-standard-amsg/instant/test/helpers.mjs +++ b/packages/rei-standard-amsg/instant/test/helpers.mjs @@ -173,14 +173,19 @@ export function createFetchRouter(routes) { return { fetch: fetchImpl, pushCalls }; } -/** Convenience: build a fake LLM response with the given content. */ -export function makeLlmResponse(content) { +/** + * Convenience: build a fake LLM response with the given content. Any + * `extra` keys are merged onto `choices[0].message`, so callers can + * inject `reasoning_content` / `tool_calls` / `refusal` without + * needing a second helper. + */ +export function makeLlmResponse(content, extra = {}) { return { ok: true, status: 200, statusText: 'OK', async json() { - return { choices: [{ message: { content } }] }; + return { choices: [{ message: { content, ...extra } }] }; }, }; } diff --git a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs new file mode 100644 index 0000000..fbd0c3f --- /dev/null +++ b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs @@ -0,0 +1,245 @@ +/** + * v0.8 ReasoningPush tests. + * + * Covers the new "auto-emit ReasoningPush before the content burst / + * before the hook" behaviour on both the legacy and the agentic-loop + * paths, plus the hook-path `autoEmitReasoning: false` opt-out. + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createInstantHandler } from '../src/index.js'; +import { + generateTestVapid, + generateTestSubscription, + createFetchRouter, + decryptCapturedPushBody, + makeLlmResponse, +} from './helpers.mjs'; + +const LLM_URL = 'https://api.example.com/v1/chat/completions'; + +let vapid; +let subKit; + +before(async () => { + vapid = await generateTestVapid(); + subKit = await generateTestSubscription(); +}); + +function makeRequest(body, headers = {}) { + return new Request('http://localhost/instant', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +function basePayload(overrides = {}) { + return { + contactName: 'Rei', + completePrompt: 'say hi', + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + ...overrides, + }; +} + +async function decryptAll(pushCalls) { + const out = []; + for (const call of pushCalls) { + out.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + return out; +} + +// ─── Legacy path ──────────────────────────────────────────────────────── + +describe('legacy path — ReasoningPush auto-emission', () => { + it('emits ReasoningPush before ContentPush when reasoning_content present', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('hello.', { reasoning_content: 'user said hi; reply briefly' }), + }); + // Disable sentence-spacing sleeps so the test stays fast — the + // production 1500ms gap is exercised by handler.test.mjs. + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + }); + // Skip the wait — drive the handler through a sleep override by + // bypassing the handler-facing wrapper. We can't pass sleep + // through `createInstantHandler`, so the test is sized to send + // one sentence + reasoning = 2 pushes. + const res = await handler(makeRequest(basePayload())); + assert.equal(res.status, 200); + assert.equal(router.pushCalls.length, 2); + + const decoded = await decryptAll(router.pushCalls); + // Reasoning first, content second. + assert.equal(decoded[0].messageKind, 'reasoning'); + assert.equal(decoded[0].reasoningContent, 'user said hi; reply briefly'); + assert.equal('messageIndex' in decoded[0], false, 'reasoning must not carry messageIndex'); + assert.equal('totalMessages' in decoded[0], false, 'reasoning must not carry totalMessages'); + + assert.equal(decoded[1].messageKind, 'content'); + assert.equal(decoded[1].message, 'hello.'); + assert.equal(decoded[1].messageIndex, 1); + assert.equal(decoded[1].totalMessages, 1); + + // Same sessionId across reasoning + content (one LLM round). + assert.equal(decoded[0].sessionId, decoded[1].sessionId); + }); + + it('does NOT emit ReasoningPush when reasoning_content is empty/absent', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('plain.'), + }); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const res = await handler(makeRequest(basePayload())); + assert.equal(res.status, 200); + assert.equal(router.pushCalls.length, 1, 'no reasoning_content → no ReasoningPush'); + const [content] = await decryptAll(router.pushCalls); + assert.equal(content.messageKind, 'content'); + }); + + it('treats whitespace-only reasoning_content as absent', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('plain.', { reasoning_content: ' \n ' }), + }); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const res = await handler(makeRequest(basePayload())); + assert.equal(res.status, 200); + assert.equal(router.pushCalls.length, 1); + }); +}); + +// ─── Hook path ────────────────────────────────────────────────────────── + +function hookPayload(overrides = {}) { + const p = basePayload({ + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + }); + // The handler rejects payloads carrying BOTH completePrompt and messages. + delete p.completePrompt; + return p; +} + +describe('hook path — ReasoningPush auto-emission', () => { + it('default config emits ReasoningPush BEFORE invoking the hook (hook honors ctx.sessionId)', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('answer text', { reasoning_content: 'thinking out loud' }), + }); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (e) => events.push(e), + // The hook is responsible for propagating ctx.sessionId into its + // own pushPayload — the framework does NOT auto-inject (the hook + // contract is `pushPayload: unknown`, fully caller-controlled). + // ctx.sessionId is exposed for exactly this purpose. + onLLMOutput: (ctx) => ({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: ctx.llmOutputText, sessionId: ctx.sessionId }, + }), + }); + const res = await handler(makeRequest(hookPayload({ sessionId: 'sess-pair-1' }))); + assert.equal(res.status, 200); + assert.equal(router.pushCalls.length, 2); + const decoded = await decryptAll(router.pushCalls); + assert.equal(decoded[0].messageKind, 'reasoning'); + assert.equal(decoded[0].reasoningContent, 'thinking out loud'); + assert.equal(decoded[1].messageKind, 'content'); + // Same sessionId across reasoning + hook's content push — the + // hook propagates it via ctx.sessionId. + assert.equal(decoded[0].sessionId, 'sess-pair-1'); + assert.equal(decoded[1].sessionId, 'sess-pair-1'); + // onEvent received the auto-push notification. + assert.ok(events.some((e) => e.type === 'reasoning_pushed'), + `expected a 'reasoning_pushed' event, got: ${JSON.stringify(events.map(e => e.type))}`); + }); + + it('autoEmitReasoning:false suppresses auto-emit even when reasoning_content present', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('answer text', { reasoning_content: 'thinking out loud' }), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + autoEmitReasoning: false, + onLLMOutput: (ctx) => ({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: ctx.llmOutputText }, + }), + }); + const res = await handler(makeRequest(hookPayload())); + assert.equal(res.status, 200); + assert.equal(router.pushCalls.length, 1, 'autoEmitReasoning:false → only the hook push'); + const [only] = await decryptAll(router.pushCalls); + assert.equal(only.messageKind, 'content'); + }); + + it('hook returning skip-push still ships the auto-emitted ReasoningPush', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever', { reasoning_content: 'I will stay silent' }), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + const res = await handler(makeRequest(hookPayload())); + const body = await res.json(); + assert.equal(body.data.status, 'skipped'); + // ReasoningPush already shipped before the hook decided to skip. + assert.equal(router.pushCalls.length, 1); + const [only] = await decryptAll(router.pushCalls); + assert.equal(only.messageKind, 'reasoning'); + }); + + it('reasoning push uses the same sessionId as subsequent agentic-loop iterations', async () => { + let llmCalls = 0; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => { + llmCalls++; + // round 1 has reasoning; round 2 emits a finish push. + return llmCalls === 1 + ? makeLlmResponse(`round-${llmCalls}`, { reasoning_content: 'planning' }) + : makeLlmResponse(`round-${llmCalls}`); + }, + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: (ctx) => { + if (ctx.iteration === 0) { + return { decision: 'continue', nextHistory: ctx.messages }; + } + return { + decision: 'finish', + pushPayload: { messageKind: 'content', message: ctx.llmOutputText, sessionId: ctx.sessionId }, + }; + }, + }); + const res = await handler(makeRequest(hookPayload({ sessionId: 'sess-stable-1' }))); + assert.equal(res.status, 200); + assert.equal(llmCalls, 2); + // 1 reasoning (iter 0) + 1 content (iter 1 finish) = 2 pushes total. + assert.equal(router.pushCalls.length, 2); + const decoded = await decryptAll(router.pushCalls); + assert.equal(decoded[0].messageKind, 'reasoning'); + assert.equal(decoded[0].sessionId, 'sess-stable-1'); + assert.equal(decoded[1].sessionId, 'sess-stable-1'); + }); +}); diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index d3f494e..ebc0308 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog — @rei-standard/amsg-server +## 2.4.0-next.0 — Three-axis push schema + ReasoningPush (pre-release) + +Published under the `next` dist-tag (repo convention for prereleases). Coordinated with the other amsg sub-packages' `*-next.0` releases. Install with `npm install @rei-standard/amsg-server@next`. Schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. + +--- + +Coordinated minor across the whole amsg ecosystem. The server's push wire shape now follows `@rei-standard/amsg-shared`'s discriminated union, indexed by `messageKind`. LLM-driven paths (`prompted` / `auto` / the via-server `instant` path) also lift `choices[0].message.reasoning_content` into a first-class `ReasoningPush` ahead of the content burst. + +### Breaking + +- **Push wire shape now follows `@rei-standard/amsg-shared`'s `AmsgPush` union.** Every push carries `messageKind: 'content' | 'reasoning'` as a literal-type discriminator. `ContentPush` keeps every field the 2.3.x 13-field shape had (`title`, `message`, `contactName`, `messageId`, `messageIndex`, `totalMessages`, `messageType`, `messageSubtype`, `taskId`, `timestamp`, `source`, `avatarUrl`, `metadata`) — plus the new `messageKind: 'content'` discriminator and `sessionId`. +- **`sessionId` is now part of every push.** Server-emitted pushes use `sess_task_` for scheduled rows (stable across retries) or `sess_` when there is no task id (the legacy in-server instant path). Same `sessionId` is shared across the auto-emitted ReasoningPush and the entire ContentPush burst from one LLM round. + +### New + +- **Auto-emit `ReasoningPush` before the content burst** when the LLM response carries non-empty `choices[0].message.reasoning_content`. Applies to `prompted`, `auto`, and the legacy in-server `instant` path. `fixed` and explicit-`userMessage` paths produce no LLM response, so the reasoning step is naturally skipped. +- **Server-driven failures continue to flow through DB `status: 'failed'`** — server does NOT push an `ErrorPush` to clients. (This is the schema-unification release, not a behavior-expansion release; the in-band push error envelope is a separate feature shipped only by `@rei-standard/amsg-instant`.) + +### Migration from 2.3.x + +| 2.3.x | 2.4.0 | +|--------------------------------------------------------|-------------------------------------------------------------------------------------| +| Hand-rolled 13-field `notificationPayload` | `buildContentPush({...})` from `@rei-standard/amsg-shared` | +| `messagesSent` reflects sentence count | Unchanged — still sentence count. ReasoningPush is auxiliary, not counted. | +| Push payload has no `messageKind` | Push payload carries `messageKind: 'content'`. SW dispatch on `payload.messageKind` | +| Push payload has no `sessionId` | Push payload carries `sessionId`. Same id across ReasoningPush + ContentPush burst | +| No reasoning push | If LLM returns non-empty `reasoning_content`, a separate `ReasoningPush` is sent first | + +If you have a SW that hand-sniffs push fields, switch to the `messageKind` discriminator. If you have a client that pairs server-sent sentences (e.g. via `messageId` regex), use `sessionId` instead — it's stable and explicit. + +### Dependencies + +- Adds `@rei-standard/amsg-shared` at exact version `0.1.0` (no caret). The coordinated minor upgrade is intentionally strict — npm shouldn't resolve a mixed-version graph across the ecosystem. + ## 2.3.2 — 2026-05-18 ### Docs diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index b95a9b2..51916a1 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,7 +1,7 @@ { "name": "@rei-standard/amsg-server", - "version": "2.3.2", - "description": "ReiStandard Active Messaging server SDK with pluggable database adapters", + "version": "2.4.0-next.0", + "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.", "repository": { "type": "git", "url": "https://github.com/Tosd0/ReiStandard", @@ -33,6 +33,7 @@ "node": ">=20" }, "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0", "web-push": "^3.6.7", "@netlify/blobs": "^8.1.0" }, diff --git a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js index 6bebed3..754d9c8 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/message-processor.js +++ b/packages/rei-standard-amsg/server/src/server/lib/message-processor.js @@ -1,13 +1,30 @@ /** * Message Processor (SDK version) - * ReiStandard SDK v2.0.1 + * ReiStandard amsg-server v2.4.0 * - * Handles single message content generation and Web Push delivery. - * Receives its dependencies (encryption helpers, webpush, VAPID config) - * via a context object so that it stays free of process.env references. + * Handles single message content generation and Web Push delivery for + * scheduled tasks (`fixed` / `prompted` / `auto`) and the legacy + * via-server instant path (`messageType: 'instant'`). + * + * Push wire shape comes from `@rei-standard/amsg-shared`'s + * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`) + * routes on `messageKind`. Server-driven pushes always carry + * `source: 'instant'` (for the legacy in-server instant) or + * `source: 'scheduled'` (for everything else). + * + * v2.4.0: when the LLM response carries non-empty + * `choices[0].message.reasoning_content`, the processor now emits a + * standalone `ReasoningPush` **before** the `ContentPush` burst. + * `messagesSent` in the return value continues to reflect the sentence + * count only (reasoning is an auxiliary push, not a sentence). */ import { randomUUID } from 'crypto'; +import { + buildContentPush, + buildReasoningPush, +} from '@rei-standard/amsg-shared'; + import { decryptFromStorage, deriveUserEncryptionKey } from './encryption.js'; const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/; @@ -59,6 +76,25 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) { return chunks.length > 0 ? chunks : [messageContent]; } +/** + * Read `choices[0].message.reasoning_content` as a non-empty trimmed + * string, or null when absent / empty. Mirrors + * `amsg-instant/src/message-processor.js#readReasoningContent`. + * + * @param {unknown} llmResponse + * @returns {string | null} + */ +function readReasoningContent(llmResponse) { + if (!llmResponse || typeof llmResponse !== 'object') return null; + const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices; + if (!Array.isArray(choices) || choices.length === 0) return null; + const message = /** @type {{ message?: { reasoning_content?: unknown } }} */ (choices[0])?.message; + const raw = message?.reasoning_content; + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + /** * @typedef {Object} ProcessorContext * @property {Object} webpush - The web-push module instance (already VAPID-configured). @@ -85,14 +121,19 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey)); let messageContent; + /** @type {unknown} */ + let llmResponse = null; if (decryptedPayload.messageType === 'fixed') { messageContent = decryptedPayload.userMessage; } else if (decryptedPayload.messageType === 'instant') { - const hasPrompt = !!decryptedPayload.completePrompt || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0); + const hasPrompt = !!decryptedPayload.completePrompt + || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0); if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) { - messageContent = await _callAI(decryptedPayload); + const aiResult = await _callAI(decryptedPayload); + messageContent = aiResult.content; + llmResponse = aiResult.response; } else if (decryptedPayload.userMessage) { messageContent = decryptedPayload.userMessage; } else { @@ -100,7 +141,9 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { } } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') { - messageContent = await _callAI(decryptedPayload); + const aiResult = await _callAI(decryptedPayload); + messageContent = aiResult.content; + llmResponse = aiResult.response; } else { throw new Error('Invalid message configuration: no content source available'); } @@ -117,25 +160,68 @@ export async function processSingleMessage(task, ctx, providedMasterKey) { } const pushSubscription = decryptedPayload.pushSubscription; + // sessionId is shared across the optional ReasoningPush and every + // ContentPush from this LLM round. Pin it to the task id when + // available (scheduled tasks) so retries reuse the same id; + // otherwise mint a UUID. + const sessionId = task.id != null + ? `sess_task_${task.id}` + : `sess_${randomUUID()}`; + const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled'; + const messageSubtype = decryptedPayload.messageSubtype || 'chat'; + const avatarUrl = decryptedPayload.avatarUrl || null; + const metadata = decryptedPayload.metadata || {}; + + // `messageId` format — deterministic when we have a task.id so a + // retry produces the same id for the same (task, sentence) pair + // (downstream dedupers can key on it). Falls back to a UUID for + // the legacy in-server instant path that has no row id. + const messageIdBase = task.id != null + ? `msg_task_${task.id}` + : `msg_${randomUUID()}_instant`; + + // ReasoningPush — auto-emitted before the content burst when the + // LLM response carried non-empty reasoning_content. `fixed` and + // explicit-userMessage paths produce no LLM response, so this + // block is naturally skipped for them (llmResponse stays null). + const reasoning = readReasoningContent(llmResponse); + if (reasoning) { + const reasoningPush = buildReasoningPush({ + messageType: decryptedPayload.messageType, + source, + messageId: `${messageIdBase}_reasoning`, + sessionId, + reasoningContent: reasoning, + timestamp: new Date().toISOString(), + title: `来自 ${decryptedPayload.contactName}`, + contactName: decryptedPayload.contactName, + avatarUrl, + messageSubtype, + metadata, + }); + await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush)); + await new Promise(resolve => setTimeout(resolve, 1500)); + } for (let i = 0; i < messages.length; i++) { - const notificationPayload = { - title: `来自 ${decryptedPayload.contactName}`, + const contentPush = buildContentPush({ + messageType: decryptedPayload.messageType, + source, + messageId: `${messageIdBase}_${i}`, + sessionId, message: messages[i], + timestamp: new Date().toISOString(), + title: `来自 ${decryptedPayload.contactName}`, contactName: decryptedPayload.contactName, - messageId: `msg_${randomUUID()}_${task.id || 'instant'}_${i}`, + avatarUrl, + messageSubtype, messageIndex: i + 1, totalMessages: messages.length, - messageType: decryptedPayload.messageType, - messageSubtype: decryptedPayload.messageSubtype || 'chat', taskId: task.id || null, - timestamp: new Date().toISOString(), - source: decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled', - avatarUrl: decryptedPayload.avatarUrl || null, - metadata: decryptedPayload.metadata || {} - }; + metadata, + }); - await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(notificationPayload)); + await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush)); if (i < messages.length - 1) { await new Promise(resolve => setTimeout(resolve, 1500)); @@ -239,7 +325,15 @@ export async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, p /** * Call an OpenAI-compatible API. + * + * Returns the full response object alongside the extracted (trimmed) + * `content` string. Callers that only need the text can ignore + * `response`; callers that want `reasoning_content` / `tool_calls` + * read from `response.choices[0].message`. + * * @private + * @param {Object} payload + * @returns {Promise<{ response: unknown, content: string }>} */ async function _callAI(payload) { const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl); @@ -276,7 +370,7 @@ async function _callAI(payload) { throw new Error('AI API error: response missing choices[0].message.content'); } - return content.trim(); + return { response: aiData, content: content.trim() }; } /** @@ -334,20 +428,6 @@ function buildAiRequestBody(payload) { * to avoid an architectural dependency (server should not depend on the * stateless worker package). * - * Rules (idempotent — running it twice equals running it once): - * - Already ends with `/chat/completions` → leave as-is. - * - Bare host (no path or just `/`) → append `/v1/chat/completions`. - * - Path ends with a version segment like `/v1`, - * `/v2`, … (with or without trailing slash) → append only - * `/chat/completions`; never doubles `/v1` for - * callers who already include it. - * - Anything else (custom path that doesn't match the - * OpenAI shape, e.g. `/v1/messages` for - * Anthropic-style proxies) → leave as-is. The caller - * knows their own routing. - * - * Query string is preserved verbatim. - * * @param {string} apiUrl * @returns {string} */ diff --git a/packages/rei-standard-amsg/server/test/message-processor.test.mjs b/packages/rei-standard-amsg/server/test/message-processor.test.mjs index 4b9a0c6..25a16ac 100644 --- a/packages/rei-standard-amsg/server/test/message-processor.test.mjs +++ b/packages/rei-standard-amsg/server/test/message-processor.test.mjs @@ -585,6 +585,239 @@ describe('splitPattern support', () => { } }); + it('processSingleMessage: emits ContentPush with messageKind/sessionId (v2.4.0 schema)', async () => { + const task = createEncryptedTask({ + contactName: 'Rei', + messageType: 'prompted', + completePrompt: 'x', + apiUrl: 'https://api.example.com/v1/chat/completions', + apiKey: 's', + primaryModel: 'm', + pushSubscription: { endpoint: 'https://push.example.com/sub' }, + }); + + const pushed = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + async json() { return { choices: [{ message: { content: '一句。二句!' } }] }; }, + }); + + const ctx = createContext(async (_sub, payload) => { + pushed.push(JSON.parse(payload)); + }); + + try { + const result = await processSingleMessage(task, ctx); + assert.equal(result.success, true); + assert.equal(result.messagesSent, 2); + assert.equal(pushed.length, 2); + for (const p of pushed) { + assert.equal(p.messageKind, 'content'); + assert.equal(p.source, 'scheduled'); + assert.equal(p.messageType, 'prompted'); + assert.match(p.sessionId, /^sess_task_/); + // Legacy 0.7.x fields still present. + assert.ok('title' in p); + assert.ok('message' in p); + assert.ok('messageIndex' in p); + assert.ok('totalMessages' in p); + assert.ok('messageId' in p); + assert.ok('messageSubtype' in p); + assert.ok('avatarUrl' in p); + assert.ok('metadata' in p); + assert.ok('taskId' in p); + } + // Same sessionId across both sentences from one LLM round. + assert.equal(pushed[0].sessionId, pushed[1].sessionId); + assert.equal(pushed[0].messageIndex, 1); + assert.equal(pushed[1].messageIndex, 2); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('processSingleMessage: auto-emits ReasoningPush before ContentPush when LLM returns reasoning_content', async () => { + const task = createEncryptedTask({ + contactName: 'Rei', + messageType: 'prompted', + completePrompt: 'x', + apiUrl: 'https://api.example.com/v1/chat/completions', + apiKey: 's', + primaryModel: 'm', + pushSubscription: { endpoint: 'https://push.example.com/sub' }, + }); + + const pushed = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + async json() { + return { + choices: [{ + message: { + content: '回答。', + reasoning_content: '先想想再回答', + }, + }], + }; + }, + }); + + const ctx = createContext(async (_sub, payload) => { + pushed.push(JSON.parse(payload)); + }); + + try { + const result = await processSingleMessage(task, ctx); + assert.equal(result.success, true); + // messagesSent reflects sentence count, NOT reasoning + sentences. + assert.equal(result.messagesSent, 1); + // But on the wire there are 2 pushes: reasoning + content. + assert.equal(pushed.length, 2); + assert.equal(pushed[0].messageKind, 'reasoning'); + assert.equal(pushed[0].reasoningContent, '先想想再回答'); + assert.equal('messageIndex' in pushed[0], false, 'reasoning must not carry messageIndex'); + assert.equal('totalMessages' in pushed[0], false, 'reasoning must not carry totalMessages'); + assert.equal(pushed[1].messageKind, 'content'); + // Same sessionId across reasoning + content. + assert.equal(pushed[0].sessionId, pushed[1].sessionId); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('processSingleMessage: does NOT emit ReasoningPush for fixed messageType (no LLM call)', async () => { + const task = createEncryptedTask({ + contactName: 'Rei', + messageType: 'fixed', + userMessage: '固定消息', + pushSubscription: { endpoint: 'https://push.example.com/sub' }, + }); + + const pushed = []; + const ctx = createContext(async (_sub, payload) => { + pushed.push(JSON.parse(payload)); + }); + + const result = await processSingleMessage(task, ctx); + assert.equal(result.success, true); + assert.equal(pushed.length, 1, 'fixed path → no reasoning, only the content push'); + assert.equal(pushed[0].messageKind, 'content'); + assert.equal(pushed[0].messageType, 'fixed'); + assert.equal(pushed[0].source, 'scheduled'); + }); + + it('processSingleMessage: messageType:"instant" routes to source:"instant" (via-server instant path)', async () => { + const task = createEncryptedTask({ + contactName: 'Rei', + messageType: 'instant', + completePrompt: 'x', + apiUrl: 'https://api.example.com/v1/chat/completions', + apiKey: 's', + primaryModel: 'm', + pushSubscription: { endpoint: 'https://push.example.com/sub' }, + }); + + const pushed = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + async json() { return { choices: [{ message: { content: 'reply' } }] }; }, + }); + + const ctx = createContext(async (_sub, payload) => { + pushed.push(JSON.parse(payload)); + }); + + try { + const result = await processSingleMessage(task, ctx); + assert.equal(result.success, true); + assert.equal(pushed[0].source, 'instant'); + assert.equal(pushed[0].messageType, 'instant'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('processSingleMessage: messageId is deterministic across retries when task.id is present', async () => { + // Pin the v2.4.0 messageId format: `msg_task__` for scheduled + // rows, so a retry produces the same id for the same (task, sentence) + // pair and downstream dedupers can key on it. + const task = createEncryptedTask({ + contactName: 'Rei', + messageType: 'prompted', + completePrompt: 'x', + apiUrl: 'https://api.example.com/v1/chat/completions', + apiKey: 's', + primaryModel: 'm', + pushSubscription: { endpoint: 'https://push.example.com/sub' }, + }); + + async function runOnce() { + const pushed = []; + const ctx = createContext(async (_sub, payload) => { + pushed.push(JSON.parse(payload)); + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + async json() { return { choices: [{ message: { content: '一句。二句!' } }] }; }, + }); + try { + await processSingleMessage(task, ctx); + return pushed; + } finally { + globalThis.fetch = originalFetch; + } + } + + const a = await runOnce(); + const b = await runOnce(); + + assert.equal(a[0].messageId, `msg_task_${task.id}_0`); + assert.equal(a[1].messageId, `msg_task_${task.id}_1`); + // Same task → same messageIds across retries. + assert.equal(a[0].messageId, b[0].messageId); + assert.equal(a[1].messageId, b[1].messageId); + }); + + it('processSingleMessage: sessionId/messageId use UUID fallback when task.id is null', async () => { + // The legacy in-server instant path can receive a task row with + // `id == null`. In that case there's no stable key to derive the + // sessionId/messageId from, so we fall back to UUIDs. + const userKey = deriveUserEncryptionKey(TEST_USER_ID, TEST_MASTER_KEY); + const encryptedPayload = encryptForStorage(JSON.stringify({ + contactName: 'Rei', + messageType: 'instant', + completePrompt: 'x', + apiUrl: 'https://api.example.com/v1/chat/completions', + apiKey: 's', + primaryModel: 'm', + pushSubscription: { endpoint: 'https://push.example.com/sub' }, + }), userKey); + const taskWithoutId = { id: null, user_id: TEST_USER_ID, encrypted_payload: encryptedPayload }; + + const pushed = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => ({ + ok: true, + async json() { return { choices: [{ message: { content: 'reply' } }] }; }, + }); + const ctx = createContext(async (_sub, payload) => { + pushed.push(JSON.parse(payload)); + }); + + try { + const result = await processSingleMessage(taskWithoutId, ctx); + assert.equal(result.success, true); + assert.match(pushed[0].sessionId, /^sess_[0-9a-f-]{36}$/); + assert.match(pushed[0].messageId, /^msg_[0-9a-f-]{36}_instant_0$/); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('processSingleMessage: cascades string[] splitPattern in order', async () => { const task = createEncryptedTask({ contactName: 'Rei', diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md new file mode 100644 index 0000000..9dd43bb --- /dev/null +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -0,0 +1,51 @@ +# @rei-standard/amsg-shared + +## 0.1.0-next.0 — initial pre-release + +Published under the `next` dist-tag (the repo's convention for prereleases — `publish-workspaces.mjs` auto-routes any version with a prerelease suffix). The schema is locked but the package is held back from `latest` until downstream integrators sign off on the wire shape end-to-end. Install with `npm install @rei-standard/amsg-shared@next`. + +--- + +New package. The lowest layer of the ReiStandard Active Messaging +ecosystem: every other amsg sub-package (`amsg-instant`, +`amsg-server`, `amsg-sw`, `amsg-client`) depends on this one, never +the reverse. + +### What's in + +- `MessageKind` / `MessageType` / `PushSource` type aliases + matching + runtime constants (`MESSAGE_KIND`, `MESSAGE_TYPE`, `PUSH_SOURCE`). +- Discriminated union `AmsgPush = ContentPush | ReasoningPush | + ToolRequestPush | ErrorPush`, with `messageKind` as the literal-type + tag (TS consumers can `switch (push.messageKind)` and narrow). +- Common-fields `@typedef` `AmsgPushCommon` capturing the universal + shape (`messageType` / `source` / `messageId` / `sessionId` / + `timestamp` / `messageSubtype?` / `metadata?`). +- Four builder helpers: `buildContentPush`, `buildReasoningPush`, + `buildToolRequestPush`, `buildErrorPush`. Each does minimum + required-field validation and returns a plain object. +- Four type guards: `isContentPush`, `isReasoningPush`, + `isToolRequestPush`, `isErrorPush`. + +### Out of scope (deliberate) + +- No `messageKind: 'tool_result'`. Tool results flow client → worker + via the `/continue` body, not as a push. +- No streaming-chunk push type. +- No tool-call schema validation (`toolCalls` is `Array` — + whatever OpenAI-compatible the upstream returned). +- Builders do not write into `metadata`. `metadata` stays a caller- + owned namespace. + +### Migration from 0.7.x callers + +The 0.7.x `amsg-instant` legacy push (13 fields, no `messageKind`) +and the standalone `{ type: 'error', code: '...' }` envelope are both +gone in the upstream packages that consume this. Use: + +| Was (0.7.x) | Now (≥ 0.1.0 of shared, ≥ 0.8.0 of instant) | +|-------------------------------------------------|--------------------------------------------------| +| 13-field instant push | `buildContentPush({...})` | +| `{ type: 'error', code: 'HOOK_THREW', ...}` | `buildErrorPush({ code: 'HOOK_THREW', ... })` | +| `{ type: 'error', code: 'LOOP_EXCEEDED', ...}` | `buildErrorPush({ code: 'LOOP_EXCEEDED', ... })` | +| (no equivalent — reasoning was discarded) | `buildReasoningPush({ reasoningContent, ... })` | diff --git a/packages/rei-standard-amsg/shared/README.md b/packages/rei-standard-amsg/shared/README.md new file mode 100644 index 0000000..cc8397c --- /dev/null +++ b/packages/rei-standard-amsg/shared/README.md @@ -0,0 +1,253 @@ +# @rei-standard/amsg-shared + +Lowest layer of the ReiStandard Active Messaging ecosystem. Defines +the **three-axis push contract** that `amsg-instant`, `amsg-server`, +`amsg-sw`, and `amsg-client` all conform to. + +Zero runtime deps. Does **not** depend on any other amsg package — +every other amsg sub-package depends on this one, never the reverse. + +--- + +## Three axes + +A single push is described by three orthogonal axes: + +| Axis | Field | Values | Defined by | +|----------------|-------------------|-------------------------------------------------------|--------------------| +| Dispatch | `messageType` | `instant` / `fixed` / `prompted` / `auto` | Package (fixed) | +| Business | `messageSubtype` | Any string | Caller (free-form) | +| Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error` | Package (fixed) | + +`messageType` answers **how this push was produced** (one-shot +`instant` worker, scheduled `fixed` ping, AI-`prompted` reply, fully +`auto`-generated cadence). `messageKind` answers **what it carries**. +The two are intentionally orthogonal: any `messageType` can carry any +`messageKind`. + +There is also `source: 'instant' | 'scheduled'` — the **routing +origin** (`'instant'` for `amsg-instant`, `'scheduled'` for any +`amsg-server` output). `messageType: 'instant'` always pairs with +`source: 'instant'`; the other three `messageType`s always pair with +`source: 'scheduled'`. + +--- + +## Common fields (every push) + +| Field | Type | Notes | +|------------------|-------------------|-----------------------------------------------------------------------------| +| `messageKind` | `MessageKind` | Discriminator. Literal type — TS narrows on it. | +| `messageType` | `MessageType` | Dispatch axis. | +| `source` | `'instant' \| 'scheduled'` | Routing origin. | +| `messageId` | `string` | Unique per push. Format owned by the producer. | +| `sessionId` | `string` | **Shared across all pushes from the same LLM round** (reasoning + content), and across all iterations of a single agentic-loop request. | +| `timestamp` | `string` (ISO 8601) | Producer-side wall clock. | +| `messageSubtype` | `string?` | Caller's business namespace. Defaults to `'chat'` at producers. | +| `metadata` | `object?` | **Caller passthrough.** Packages MUST NOT write here. | + +--- + +## Per-kind fields + +### `ContentPush` — final user-facing content + +| Field | Type | Notes | +|------------------|-------------|----------------------------------------------------------------| +| `messageKind` | `'content'` | Discriminator. | +| `message` | `string` | The sentence/segment to display. | +| `messageIndex` | `number?` | 1-based segment index within an N-split burst. Omit for singletons. | +| `totalMessages` | `number?` | Total segments in the burst. Omit for singletons. | +| `title` | `string?` | Notification title. | +| `contactName` | `string?` | Sender display name. | +| `avatarUrl` | `string \| null?` | Sender avatar URL (`https:` only — `data:` is rejected upstream). | +| `taskId` | `string \| null?` | Scheduled task ID (server only). | + +### `ReasoningPush` — LLM meta-thinking + +| Field | Type | Notes | +|--------------------|----------------|-------------------------------------------------------------| +| `messageKind` | `'reasoning'` | Discriminator. | +| `reasoningContent` | `string` | Lifted from `choices[0].message.reasoning_content`. | +| `title` | `string?` | | +| `contactName` | `string?` | | +| `avatarUrl` | `string \| null?` | | + +**No `messageIndex` / `totalMessages`.** Reasoning is one push per +LLM round, never a split-burst. Those fields are absent at the type +level on purpose — making them optional would leave callers +wondering when they're set. + +Emitted **before** the matching `ContentPush` burst when the LLM +response carried a non-empty `reasoning_content`. + +### `ToolRequestPush` — tool invocation request + +| Field | Type | Notes | +|---------------|------------------|-------------------------------------------------------------| +| `messageKind` | `'tool_request'` | Discriminator. | +| `toolCalls` | `Array` | OpenAI `choices[0].message.tool_calls` shape, passthrough. | +| `title` | `string?` | | +| `contactName` | `string?` | | +| `message` | `string?` | Optional human-readable tag for the request. | + +Emitted by an agentic-loop hook returning +`{ decision: 'tool-request', pushPayload }`. The client is expected +to execute the tool and resume via `/continue`. + +### `ErrorPush` — producer-level error + +| Field | Type | Notes | +|---------------|-----------|------------------------------------------------------------------------| +| `messageKind` | `'error'` | Discriminator. | +| `code` | `string` | Stable producer-defined code, e.g. `HOOK_THREW`, `LOOP_EXCEEDED`. | +| `message` | `string` | Human-readable description. | +| `iteration` | `number?` | Agentic-loop iteration when relevant. | + +Replaces the legacy 0.7.0 `{ type: 'error', code: '...' }` envelope. +The legacy `type` field is **gone** — do not look for it on +`ErrorPush`. + +--- + +## Usage + +### TypeScript / typed JavaScript + +```ts +import { + type AmsgPush, + type ContentPush, + type ReasoningPush, + isContentPush, +} from '@rei-standard/amsg-shared'; + +function dispatch(push: AmsgPush) { + switch (push.messageKind) { + case 'content': + // push narrowed to ContentPush — push.message is `string` + console.log(push.message); + break; + case 'reasoning': + // push narrowed to ReasoningPush — push.reasoningContent is `string` + console.log(push.reasoningContent); + break; + case 'tool_request': + // push.toolCalls is `Array` + break; + case 'error': + console.error(push.code, push.message); + break; + } +} +``` + +### Builders + +```js +import { + buildContentPush, + buildReasoningPush, + buildToolRequestPush, + buildErrorPush, +} from '@rei-standard/amsg-shared'; + +// One sentence in an N-split burst +const content = buildContentPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${crypto.randomUUID()}_0`, + sessionId: 'sess_abc', + message: 'Hello!', + contactName: 'Rei', + messageIndex: 1, + totalMessages: 2, +}); + +// Reasoning emitted before the content burst +const reasoning = buildReasoningPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${crypto.randomUUID()}_reasoning`, + sessionId: 'sess_abc', // SAME sessionId as the content above + reasoningContent: 'User greeted me; I should reply warmly.', +}); + +// Agentic-loop tool request +const toolReq = buildToolRequestPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${crypto.randomUUID()}_tool`, + sessionId: 'sess_abc', + toolCalls: [{ id: 'call_0', type: 'function', function: { name: 'get_weather', arguments: '{}' } }], +}); + +// Producer-level error +const error = buildErrorPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${crypto.randomUUID()}_err`, + sessionId: 'sess_abc', + code: 'HOOK_THREW', + message: 'onLLMOutput threw: ...', + iteration: 2, +}); +``` + +### Type guards + +```js +import { isContentPush, isReasoningPush, isErrorPush } from '@rei-standard/amsg-shared'; + +if (isContentPush(push)) { + // push.message is `string` +} +``` + +--- + +## Constants + +```js +import { MESSAGE_KIND, MESSAGE_TYPE, PUSH_SOURCE } from '@rei-standard/amsg-shared'; + +MESSAGE_KIND.CONTENT; // 'content' +MESSAGE_KIND.REASONING; // 'reasoning' +MESSAGE_KIND.TOOL_REQUEST; // 'tool_request' +MESSAGE_KIND.ERROR; // 'error' + +MESSAGE_TYPE.INSTANT; // 'instant' +MESSAGE_TYPE.FIXED; // 'fixed' +MESSAGE_TYPE.PROMPTED; // 'prompted' +MESSAGE_TYPE.AUTO; // 'auto' + +PUSH_SOURCE.INSTANT; // 'instant' +PUSH_SOURCE.SCHEDULED; // 'scheduled' +``` + +--- + +## Invariants + +1. **`messageKind` is a literal-type discriminator.** Producers must + set it via a builder (or to one of the literal values directly). + Never `string`-typed. +2. **`sessionId` is stable across a single LLM round.** A + `ReasoningPush` and the `ContentPush`(es) it precedes share the + same `sessionId`. Agentic-loop multi-iteration runs reuse the + same `sessionId` across iterations. +3. **`ReasoningPush` carries no `messageIndex` / `totalMessages`.** + Those fields belong to the content N-split burst. +4. **`metadata` is caller-owned.** Packages must add protocol-level + data as top-level fields, never inside `metadata`. +5. **`source` is the routing origin, not the dispatch type.** + `'instant'` ⇄ `amsg-instant`; `'scheduled'` ⇄ `amsg-server`. + +See [§6 of `standards/active-messaging-api.md`](../../../standards/active-messaging-api.md) +for the wire-level contract. + +--- + +## License + +MIT diff --git a/packages/rei-standard-amsg/shared/package.json b/packages/rei-standard-amsg/shared/package.json new file mode 100644 index 0000000..796ce97 --- /dev/null +++ b/packages/rei-standard-amsg/shared/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rei-standard/amsg-shared", + "version": "0.1.0-next.0", + "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)", + "repository": { + "type": "git", + "url": "https://github.com/Tosd0/ReiStandard", + "directory": "packages/rei-standard-amsg/shared" + }, + "license": "MIT", + "type": "module", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.json && node -e \"require('fs').copyFileSync('dist/index.d.ts', 'dist/index.d.cts')\"", + "test": "node --test test/*.test.mjs" + }, + "engines": { + "node": ">=20" + }, + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/rei-standard-amsg/shared/src/index.js b/packages/rei-standard-amsg/shared/src/index.js new file mode 100644 index 0000000..ef16b87 --- /dev/null +++ b/packages/rei-standard-amsg/shared/src/index.js @@ -0,0 +1,440 @@ +/** + * @rei-standard/amsg-shared + * + * Lowest layer of the ReiStandard Active Messaging ecosystem. + * Defines the three-axis push contract that `amsg-instant`, + * `amsg-server`, `amsg-sw`, and `amsg-client` all conform to. + * + * Three orthogonal axes: + * 1. messageType — how the push was produced (instant / fixed / prompted / auto) + * 2. messageSubtype — caller's business classification (free-form string) + * 3. messageKind — what the push carries (content / reasoning / tool_request / error) + * + * Zero runtime dependencies. The package is ESM/CJS dual-published and + * intentionally has no `dependencies:` entry — every other amsg sub- + * package depends on it, never the reverse. + * + * Types are expressed via JSDoc `@typedef` unions with literal-type + * discriminators so TS consumers can narrow on `messageKind`: + * + * if (push.messageKind === 'reasoning') { + * // TS knows: push is ReasoningPush, push.reasoningContent is string + * } + */ + +// ─── Discriminator enums ──────────────────────────────────────────────── + +/** + * What the push carries. Fixed enum — packages must not add values. + * + * @typedef {'content' | 'reasoning' | 'tool_request' | 'error'} MessageKind + */ + +/** + * How the push was produced. Fixed enum — packages must not add values. + * + * @typedef {'instant' | 'fixed' | 'prompted' | 'auto'} MessageType + */ + +/** + * Which sub-package routed the push. Fixed enum — `'instant'` for + * `amsg-instant` (stateless one-shot), `'scheduled'` for any + * `amsg-server` output regardless of `messageType`. Packages must not + * add values. + * + * @typedef {'instant' | 'scheduled'} PushSource + */ + +/** + * Runtime constant mirroring the {@link MessageKind} type. Useful for + * switch statements that need to enumerate every kind: + * + * for (const kind of Object.values(MESSAGE_KIND)) { ... } + */ +export const MESSAGE_KIND = Object.freeze({ + CONTENT: 'content', + REASONING: 'reasoning', + TOOL_REQUEST: 'tool_request', + ERROR: 'error', +}); + +/** + * Runtime constant mirroring the {@link MessageType} type. + */ +export const MESSAGE_TYPE = Object.freeze({ + INSTANT: 'instant', + FIXED: 'fixed', + PROMPTED: 'prompted', + AUTO: 'auto', +}); + +/** + * Runtime constant mirroring the {@link PushSource} type. + */ +export const PUSH_SOURCE = Object.freeze({ + INSTANT: 'instant', + SCHEDULED: 'scheduled', +}); + +// ─── Common shape (fields on every kind) ──────────────────────────────── + +/** + * Fields present on every push, regardless of kind. Discriminator + * fields (`messageKind`) and kind-specific fields live on the kind + * interfaces below. + * + * `metadata` is a passthrough namespace owned by the caller. Packages + * are forbidden from writing their own fields into `metadata` — any + * protocol-level data goes on top-level fields. + * + * @typedef {Object} AmsgPushCommon + * @property {MessageType} messageType - How the push was produced. + * @property {PushSource} source - Which sub-package routed it. + * @property {string} messageId - Unique per push. Format owned by the producer. + * @property {string} sessionId - Shared across all pushes from one LLM round (reasoning + content) and across iterations of a single agentic-loop request. + * @property {string} timestamp - ISO 8601 timestamp at producer. + * @property {string} [messageSubtype] - Caller-defined business namespace. Defaults to 'chat' at producers. + * @property {Object} [metadata] - Caller passthrough. Packages MUST NOT write here. + */ + +// ─── Per-kind interfaces ──────────────────────────────────────────────── + +/** + * Final user-facing content. Sentence-split bursts of N use + * `messageIndex` (1-based) + `totalMessages` so the client can + * reassemble or animate. + * + * @typedef {AmsgPushCommon & { + * messageKind: 'content', + * message: string, + * title?: string, + * contactName?: string, + * avatarUrl?: string | null, + * messageIndex?: number, + * totalMessages?: number, + * taskId?: string | null, + * }} ContentPush + */ + +/** + * LLM "meta-thinking" — `choices[0].message.reasoning_content` lifted + * out of the upstream response into its own push. Emitted **before** + * the matching {@link ContentPush} burst when present and non-empty. + * + * Intentionally does NOT carry `messageIndex` / `totalMessages` — + * reasoning is a single push per LLM round, never a split-burst. + * That's why those fields are absent at the type level rather than + * `optional` (which would leave callers wondering when they're set). + * + * @typedef {AmsgPushCommon & { + * messageKind: 'reasoning', + * reasoningContent: string, + * title?: string, + * contactName?: string, + * avatarUrl?: string | null, + * }} ReasoningPush + */ + +/** + * Tool invocation request emitted by an agentic-loop hook (`decision: + * 'tool-request'`). The client is expected to execute the tool and + * resume via the producer's `/continue` endpoint. + * + * `toolCalls` mirrors the OpenAI `choices[0].message.tool_calls` + * shape — left as `any`-equivalent so producers can passthrough + * whatever OpenAI-compatible upstream returned. + * + * @typedef {AmsgPushCommon & { + * messageKind: 'tool_request', + * toolCalls: Array, + * title?: string, + * contactName?: string, + * message?: string, + * }} ToolRequestPush + */ + +/** + * Producer-level error. Replaces the legacy + * `{ type: 'error', code: '...' }` envelope. `code` is a stable + * string; `iteration` is the agentic-loop iteration number when + * relevant (0 / absent otherwise). + * + * @typedef {AmsgPushCommon & { + * messageKind: 'error', + * code: string, + * message: string, + * iteration?: number, + * }} ErrorPush + */ + +/** + * Discriminated union of all pushes the SW can receive. TS consumers + * `switch` on `messageKind` and the compiler narrows automatically. + * + * @typedef {ContentPush | ReasoningPush | ToolRequestPush | ErrorPush} AmsgPush + */ + +// ─── Builder helpers ──────────────────────────────────────────────────── +// +// Each builder takes the kind-specific fields plus the common ones and +// returns a plain object. The package does NOT validate beyond the +// minimum needed to keep the type discriminators stable — callers may +// pass extra fields freely (subject to the SW's tolerance for unknown +// keys). +// +// Use these builders to avoid drift across `amsg-instant` and +// `amsg-server`, but they aren't mandatory: hook callers in +// `amsg-instant` can return any object whose shape matches the union. + +/** + * Throw if a field that must be present is missing. Producers should + * surface a clear error rather than silently emit a malformed push. + * + * @param {string} kind + * @param {string} field + * @param {unknown} value + */ +function requireField(kind, field, value) { + if (value === undefined || value === null || value === '') { + throw new Error(`[amsg-shared] ${kind}: '${field}' is required`); + } +} + +/** + * Build a {@link ContentPush}. Use this for legacy sentence-split + * bursts (set `messageIndex` 1-based + `totalMessages`) or for a + * single content push (omit both). + * + * @param {Object} args + * @param {MessageType} args.messageType + * @param {PushSource} args.source + * @param {string} args.messageId + * @param {string} args.sessionId + * @param {string} args.message + * @param {string} [args.timestamp] - Defaults to `new Date().toISOString()`. + * @param {string} [args.title] + * @param {string} [args.contactName] + * @param {string | null} [args.avatarUrl] + * @param {string} [args.messageSubtype] + * @param {number} [args.messageIndex] + * @param {number} [args.totalMessages] + * @param {string | null} [args.taskId] + * @param {Object} [args.metadata] + * @returns {ContentPush} + */ +export function buildContentPush(args) { + requireField('ContentPush', 'messageType', args.messageType); + requireField('ContentPush', 'source', args.source); + requireField('ContentPush', 'messageId', args.messageId); + requireField('ContentPush', 'sessionId', args.sessionId); + if (typeof args.message !== 'string') { + throw new Error("[amsg-shared] ContentPush: 'message' must be a string"); + } + + /** @type {ContentPush} */ + const push = { + messageKind: 'content', + messageType: args.messageType, + source: args.source, + messageId: args.messageId, + sessionId: args.sessionId, + timestamp: args.timestamp || new Date().toISOString(), + message: args.message, + }; + if (args.title !== undefined) push.title = args.title; + if (args.contactName !== undefined) push.contactName = args.contactName; + if (args.avatarUrl !== undefined) push.avatarUrl = args.avatarUrl; + if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; + if (args.messageIndex !== undefined) push.messageIndex = args.messageIndex; + if (args.totalMessages !== undefined) push.totalMessages = args.totalMessages; + if (args.taskId !== undefined) push.taskId = args.taskId; + if (args.metadata !== undefined) push.metadata = args.metadata; + return push; +} + +/** + * Build a {@link ReasoningPush}. Producers emit this **before** any + * matching `ContentPush` burst when the LLM response carried a non- + * empty `reasoning_content`. + * + * Does NOT take `messageIndex` / `totalMessages` — reasoning is one + * push per LLM round. + * + * @param {Object} args + * @param {MessageType} args.messageType + * @param {PushSource} args.source + * @param {string} args.messageId + * @param {string} args.sessionId + * @param {string} args.reasoningContent + * @param {string} [args.timestamp] + * @param {string} [args.title] + * @param {string} [args.contactName] + * @param {string | null} [args.avatarUrl] + * @param {string} [args.messageSubtype] + * @param {Object} [args.metadata] + * @returns {ReasoningPush} + */ +export function buildReasoningPush(args) { + requireField('ReasoningPush', 'messageType', args.messageType); + requireField('ReasoningPush', 'source', args.source); + requireField('ReasoningPush', 'messageId', args.messageId); + requireField('ReasoningPush', 'sessionId', args.sessionId); + if (typeof args.reasoningContent !== 'string' || !args.reasoningContent) { + throw new Error("[amsg-shared] ReasoningPush: 'reasoningContent' must be a non-empty string"); + } + + /** @type {ReasoningPush} */ + const push = { + messageKind: 'reasoning', + messageType: args.messageType, + source: args.source, + messageId: args.messageId, + sessionId: args.sessionId, + timestamp: args.timestamp || new Date().toISOString(), + reasoningContent: args.reasoningContent, + }; + if (args.title !== undefined) push.title = args.title; + if (args.contactName !== undefined) push.contactName = args.contactName; + if (args.avatarUrl !== undefined) push.avatarUrl = args.avatarUrl; + if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; + if (args.metadata !== undefined) push.metadata = args.metadata; + return push; +} + +/** + * Build a {@link ToolRequestPush}. Caller is expected to executed + * tools client-side and resume via `/continue` (see `amsg-instant` + * README §Agentic Loop). + * + * @param {Object} args + * @param {MessageType} args.messageType + * @param {PushSource} args.source + * @param {string} args.messageId + * @param {string} args.sessionId + * @param {Array} args.toolCalls + * @param {string} [args.timestamp] + * @param {string} [args.title] + * @param {string} [args.contactName] + * @param {string} [args.message] + * @param {string} [args.messageSubtype] + * @param {Object} [args.metadata] + * @returns {ToolRequestPush} + */ +export function buildToolRequestPush(args) { + requireField('ToolRequestPush', 'messageType', args.messageType); + requireField('ToolRequestPush', 'source', args.source); + requireField('ToolRequestPush', 'messageId', args.messageId); + requireField('ToolRequestPush', 'sessionId', args.sessionId); + if (!Array.isArray(args.toolCalls) || args.toolCalls.length === 0) { + throw new Error("[amsg-shared] ToolRequestPush: 'toolCalls' must be a non-empty array"); + } + + /** @type {ToolRequestPush} */ + const push = { + messageKind: 'tool_request', + messageType: args.messageType, + source: args.source, + messageId: args.messageId, + sessionId: args.sessionId, + timestamp: args.timestamp || new Date().toISOString(), + toolCalls: args.toolCalls, + }; + if (args.title !== undefined) push.title = args.title; + if (args.contactName !== undefined) push.contactName = args.contactName; + if (args.message !== undefined) push.message = args.message; + if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; + if (args.metadata !== undefined) push.metadata = args.metadata; + return push; +} + +/** + * Build an {@link ErrorPush}. Replaces the legacy + * `{ type: 'error', code: '...' }` envelope. The new shape carries + * the full common-fields set so the SW can route it through the + * same `messageKind` switch as the other three kinds. + * + * @param {Object} args + * @param {MessageType} args.messageType + * @param {PushSource} args.source + * @param {string} args.messageId + * @param {string} args.sessionId + * @param {string} args.code + * @param {string} args.message + * @param {string} [args.timestamp] + * @param {number} [args.iteration] + * @param {string} [args.messageSubtype] + * @param {Object} [args.metadata] + * @returns {ErrorPush} + */ +export function buildErrorPush(args) { + requireField('ErrorPush', 'messageType', args.messageType); + requireField('ErrorPush', 'source', args.source); + requireField('ErrorPush', 'messageId', args.messageId); + requireField('ErrorPush', 'sessionId', args.sessionId); + requireField('ErrorPush', 'code', args.code); + if (typeof args.message !== 'string') { + throw new Error("[amsg-shared] ErrorPush: 'message' must be a string"); + } + + /** @type {ErrorPush} */ + const push = { + messageKind: 'error', + messageType: args.messageType, + source: args.source, + messageId: args.messageId, + sessionId: args.sessionId, + timestamp: args.timestamp || new Date().toISOString(), + code: args.code, + message: args.message, + }; + if (args.iteration !== undefined) push.iteration = args.iteration; + if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; + if (args.metadata !== undefined) push.metadata = args.metadata; + return push; +} + +// ─── Narrowing helpers ────────────────────────────────────────────────── + +/** + * Type guard: returns true if the argument is a {@link ContentPush}. + * + * @param {unknown} value + * @returns {value is ContentPush} + */ +export function isContentPush(value) { + return !!value && typeof value === 'object' + && /** @type {{messageKind?: unknown}} */ (value).messageKind === 'content'; +} + +/** + * Type guard: returns true if the argument is a {@link ReasoningPush}. + * + * @param {unknown} value + * @returns {value is ReasoningPush} + */ +export function isReasoningPush(value) { + return !!value && typeof value === 'object' + && /** @type {{messageKind?: unknown}} */ (value).messageKind === 'reasoning'; +} + +/** + * Type guard: returns true if the argument is a {@link ToolRequestPush}. + * + * @param {unknown} value + * @returns {value is ToolRequestPush} + */ +export function isToolRequestPush(value) { + return !!value && typeof value === 'object' + && /** @type {{messageKind?: unknown}} */ (value).messageKind === 'tool_request'; +} + +/** + * Type guard: returns true if the argument is an {@link ErrorPush}. + * + * @param {unknown} value + * @returns {value is ErrorPush} + */ +export function isErrorPush(value) { + return !!value && typeof value === 'object' + && /** @type {{messageKind?: unknown}} */ (value).messageKind === 'error'; +} diff --git a/packages/rei-standard-amsg/shared/test/builders.test.mjs b/packages/rei-standard-amsg/shared/test/builders.test.mjs new file mode 100644 index 0000000..d626137 --- /dev/null +++ b/packages/rei-standard-amsg/shared/test/builders.test.mjs @@ -0,0 +1,173 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MESSAGE_KIND, + MESSAGE_TYPE, + PUSH_SOURCE, + buildContentPush, + buildReasoningPush, + buildToolRequestPush, + buildErrorPush, + isContentPush, + isReasoningPush, + isToolRequestPush, + isErrorPush, +} from '../src/index.js'; + +const COMMON = Object.freeze({ + messageType: 'instant', + source: 'instant', + messageId: 'msg_test_0', + sessionId: 'sess_test_0', +}); + +test('MESSAGE_KIND constant enumerates all four kinds', () => { + assert.deepEqual( + new Set(Object.values(MESSAGE_KIND)), + new Set(['content', 'reasoning', 'tool_request', 'error']), + ); +}); + +test('MESSAGE_TYPE constant enumerates the four dispatch types', () => { + assert.deepEqual( + new Set(Object.values(MESSAGE_TYPE)), + new Set(['instant', 'fixed', 'prompted', 'auto']), + ); +}); + +test('PUSH_SOURCE constant enumerates the two source values', () => { + assert.deepEqual( + new Set(Object.values(PUSH_SOURCE)), + new Set(['instant', 'scheduled']), + ); +}); + +test('buildContentPush returns a ContentPush with messageKind:"content"', () => { + const push = buildContentPush({ + ...COMMON, + message: 'hello', + messageIndex: 1, + totalMessages: 2, + }); + assert.equal(push.messageKind, 'content'); + assert.equal(push.message, 'hello'); + assert.equal(push.messageIndex, 1); + assert.equal(push.totalMessages, 2); + assert.ok(typeof push.timestamp === 'string' && push.timestamp.length > 0); + assert.ok(isContentPush(push)); + assert.equal(isReasoningPush(push), false); +}); + +test('buildContentPush rejects missing message', () => { + assert.throws( + () => buildContentPush({ ...COMMON, message: undefined }), + /must be a string/, + ); +}); + +test('buildContentPush forwards passthrough metadata without mutating', () => { + const metadata = { app: 'test', nested: { k: 1 } }; + const push = buildContentPush({ ...COMMON, message: 'hi', metadata }); + assert.equal(push.metadata, metadata); +}); + +test('buildReasoningPush returns a ReasoningPush without index/total fields', () => { + const push = buildReasoningPush({ + ...COMMON, + reasoningContent: 'thinking out loud', + }); + assert.equal(push.messageKind, 'reasoning'); + assert.equal(push.reasoningContent, 'thinking out loud'); + assert.ok(!('messageIndex' in push)); + assert.ok(!('totalMessages' in push)); + assert.ok(isReasoningPush(push)); +}); + +test('buildReasoningPush rejects empty reasoningContent', () => { + assert.throws( + () => buildReasoningPush({ ...COMMON, reasoningContent: '' }), + /non-empty string/, + ); +}); + +test('buildToolRequestPush requires a non-empty toolCalls array', () => { + const push = buildToolRequestPush({ + ...COMMON, + toolCalls: [{ id: 'call_0', type: 'function', function: { name: 'noop', arguments: '{}' } }], + }); + assert.equal(push.messageKind, 'tool_request'); + assert.equal(push.toolCalls.length, 1); + assert.ok(isToolRequestPush(push)); + + assert.throws( + () => buildToolRequestPush({ ...COMMON, toolCalls: [] }), + /non-empty array/, + ); +}); + +test('buildErrorPush replaces the legacy {type:"error"} envelope', () => { + const push = buildErrorPush({ + ...COMMON, + code: 'HOOK_THREW', + message: 'onLLMOutput threw', + iteration: 3, + }); + assert.equal(push.messageKind, 'error'); + assert.equal(push.code, 'HOOK_THREW'); + assert.equal(push.message, 'onLLMOutput threw'); + assert.equal(push.iteration, 3); + // The legacy `type: 'error'` field MUST NOT be present. + assert.ok(!('type' in push), `legacy 'type' field leaked into ErrorPush: ${JSON.stringify(push)}`); + assert.ok(isErrorPush(push)); +}); + +test('type guards narrow correctly across union members', () => { + const content = buildContentPush({ ...COMMON, message: 'x' }); + const reasoning = buildReasoningPush({ ...COMMON, reasoningContent: 'y' }); + const tool = buildToolRequestPush({ ...COMMON, toolCalls: [{ id: 'a' }] }); + const error = buildErrorPush({ ...COMMON, code: 'E', message: 'm' }); + + for (const push of [content, reasoning, tool, error]) { + const matches = [ + isContentPush(push), + isReasoningPush(push), + isToolRequestPush(push), + isErrorPush(push), + ].filter(Boolean).length; + assert.equal(matches, 1, `exactly one guard should match ${push.messageKind}`); + } +}); + +test('builders forbid the package from writing into metadata', () => { + // Builders accept caller metadata as-is. Confirm the builder does not + // inject any package-owned keys at the top of metadata. + const metadata = {}; + buildContentPush({ ...COMMON, message: 'x', metadata }); + buildReasoningPush({ ...COMMON, reasoningContent: 'y', metadata }); + buildErrorPush({ ...COMMON, code: 'E', message: 'm', metadata }); + buildToolRequestPush({ ...COMMON, toolCalls: [{ id: 'c0' }], metadata }); + assert.deepEqual(Object.keys(metadata), [], 'builders must not mutate caller metadata'); +}); + +test('builders never write through a frozen metadata (catches future "merge" regressions)', () => { + // Deeper guarantee than "no observable key was added": if a future + // change ever switches to `Object.assign(metadata, ...)` or + // `metadata.something = ...`, freezing the input makes the write + // throw in strict mode. This locks the no-mutate invariant in. + const frozen = Object.freeze({ caller: 'owns this' }); + assert.doesNotThrow(() => buildContentPush({ ...COMMON, message: 'x', metadata: frozen })); + assert.doesNotThrow(() => buildReasoningPush({ ...COMMON, reasoningContent: 'y', metadata: frozen })); + assert.doesNotThrow(() => buildToolRequestPush({ ...COMMON, toolCalls: [{ id: 'c0' }], metadata: frozen })); + assert.doesNotThrow(() => buildErrorPush({ ...COMMON, code: 'E', message: 'm', metadata: frozen })); +}); + +test('required fields reject empty string (not just undefined) for ID-shaped fields', () => { + // The shared `requireField` treats `''` as missing for every required + // field. Pinning the behavior here so a future refactor that loosens + // it must update this test deliberately. + assert.throws(() => buildContentPush({ ...COMMON, messageId: '', message: 'x' }), /'messageId' is required/); + assert.throws(() => buildContentPush({ ...COMMON, sessionId: '', message: 'x' }), /'sessionId' is required/); + assert.throws(() => buildReasoningPush({ ...COMMON, messageId: '', reasoningContent: 'y' }), /'messageId' is required/); + assert.throws(() => buildErrorPush({ ...COMMON, code: '', message: 'm' }), /'code' is required/); +}); diff --git a/packages/rei-standard-amsg/shared/tsconfig.json b/packages/rei-standard-amsg/shared/tsconfig.json new file mode 100644 index 0000000..3999a8d --- /dev/null +++ b/packages/rei-standard-amsg/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "rootDir": "src", + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true + }, + "include": ["src/index.js"] +} diff --git a/packages/rei-standard-amsg/shared/tsup.config.js b/packages/rei-standard-amsg/shared/tsup.config.js new file mode 100644 index 0000000..cdefca0 --- /dev/null +++ b/packages/rei-standard-amsg/shared/tsup.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: { index: 'src/index.js' }, + format: ['cjs', 'esm'], + // dts is emitted by a separate `tsc --allowJs --emitDeclarationOnly` + // step in the build script — tsup's bundled dts plugin does not + // extract JSDoc `@typedef`s from .js entries, so it would otherwise + // ship the JS source verbatim as the .d.ts and TS consumers would + // see zero types. + dts: false, + outDir: 'dist', + outExtension({ format }) { + return { js: format === 'esm' ? '.mjs' : '.cjs' }; + }, + platform: 'neutral', + target: 'es2020', + splitting: false, + clean: true +}); diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md new file mode 100644 index 0000000..4ad0506 --- /dev/null +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog — @rei-standard/amsg-sw + +## 2.1.0-next.0 — Three-axis push schema + per-kind client events (pre-release) + +Published under the `next` dist-tag (repo convention for prereleases). Coordinated with the other amsg sub-packages' `*-next.0` releases. Install with `npm install @rei-standard/amsg-sw@next`. Schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. + +--- + +Coordinated minor with the rest of the amsg ecosystem. The SW now consumes the `AmsgPush` discriminated union from `@rei-standard/amsg-shared` (keyed by `payload.messageKind`) and bridges every push to controlled clients via a per-kind `postMessage` channel, so apps can render `reasoning` / `tool_request` / `error` in-app without going through the OS notification surface. + +### New + +- **`REI_SW_EVENT` constants** — per-kind event names dispatched to clients. Five values: `CONTENT_RECEIVED` / `REASONING_RECEIVED` / `TOOL_REQUEST_RECEIVED` / `ERROR_RECEIVED` / `UNKNOWN_RECEIVED`. The last one is the back-compat path for 2.0.x payloads (and blob envelopes) that lack `messageKind`. +- **`REI_AMSG_POSTMESSAGE_TYPE` constant** (= `'REI_AMSG_PUSH'`) — the `type` field on every SW → client envelope. Clients filter on this before reading `event` so a single `message` listener can coexist with other postMessage protocols. +- **Per-kind client dispatch.** Every push the SW receives is mirrored to every controlled window via `client.postMessage({ type: 'REI_AMSG_PUSH', event, payload })`. `clients.matchAll` runs with `{ type: 'window', includeUncontrolled: true }` so the broadcast reaches pages that haven't yet claimed the SW. +- **Blob envelope dispatch.** Envelopes like `{ _blob: true, key, url, messageKind, type? }` are forwarded to clients verbatim with the matching per-kind event name. The SW does NOT auto-fetch the blob body — the client decides whether and when to fetch. +- **Runtime dep on `@rei-standard/amsg-shared@0.1.0`** (exact, no caret). The SW code only references shared types via JSDoc `@typedef`; no runtime symbol is imported. Listing the dep keeps the package present in the dependency graph for hoisting / type resolution alongside `amsg-instant` and `amsg-server`. +- **First test suite.** `test/dispatch.test.mjs` covers every dispatch branch using a lightweight `ServiceWorkerGlobalScope` mock — no real Workbox or sw environment needed. The package now ships a real `npm test` script (`node --test test/*.test.mjs`). + +### Behavioral + +- **`showNotification` only fires for content kinds.** Concretely, the SW renders a notification iff `payload.messageKind === 'content'` OR `messageKind` is absent (legacy 2.0.x back-compat). `reasoning` / `tool_request` / `error` are dispatched to clients but render nothing on the OS notification surface. Same rule applies to blob envelopes — only `messageKind === 'content'` (or absent) renders a placeholder notification. +- **Per-client `postMessage` failures are swallowed.** One offline / broken tab should not abort delivery to the rest. The `dispatchPushToClients` helper wraps each `postMessage` in its own try/catch. +- **`clients.matchAll` rejection is non-fatal.** If the call rejects, the SW still attempts `showNotification` for `content` payloads — notification rendering is independent of the broadcast path. +- **Dispatch order is best-effort parallel.** The SW kicks off `postMessage` broadcasting and `showNotification` together inside one `event.waitUntil(Promise.all(...))`. Clients should not assume the notification has been rendered (or vice versa) before the message arrives. +- **Existing `REI_SW_MESSAGE_TYPE` queue API is unchanged.** Enqueue / flush / sync paths are unaffected — the new dispatch logic only adds to the `push` listener. + +### Migration + +- **Apps that want desktop notifications for non-content kinds must implement them in-app.** Listen on `navigator.serviceWorker.addEventListener('message', ...)`, filter by `e.data.type === 'REI_AMSG_PUSH'`, switch on `e.data.event`, and call `Notification.requestPermission()` + `new Notification(...)` (or `registration.showNotification`) yourself for the kinds you care about. The SW intentionally no longer makes that decision for you. +- **No producer-side change is required** for 2.0.x callers that have not yet adopted the three-axis schema — their payloads route through `UNKNOWN_RECEIVED` and still render notifications via the existing path. +- **TS / JSDoc users** can pull `AmsgPush`, `ContentPush`, etc. from `@rei-standard/amsg-shared` to type the client-side `e.data.payload`. The SW package itself only references those types via JSDoc and does not re-export them. + +## 2.0.1 + +- Maintenance release. No behavioral changes documented prior to this changelog. + +## 2.0.0 + +- Initial public release of the v2 SW SDK with `installReiSW` + offline queue. diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index bf82191..ff1b306 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -3,9 +3,53 @@ `@rei-standard/amsg-sw` 是 ReiStandard 主动消息标准的 Service Worker 插件包,目标是让推送展示和离线重试“开箱即用”。 +## v2.1.0 — 按 kind 分发的客户端事件 + +2.1.0 跟随 `@rei-standard/amsg-shared` 的三轴 push schema:每条 push 现在通过 `payload.messageKind`(`content` / `reasoning` / `tool_request` / `error`)区分内容类型。SW 在收到 push 后会做两件事: + +1. **永远** 通过 `postMessage` 把 payload 广播给所有受控窗口(包括 `includeUncontrolled: true` 的未受控窗口)。 +2. **仅当** `messageKind === 'content'` 或 payload 没有 `messageKind`(2.0.x 老 payload 的回退路径)时,才调用 `showNotification`。`reasoning` / `tool_request` / `error` 三种 kind 一律不弹通知——业务在 app 内通过 postMessage 通道自行渲染。 + +### 新增导出 `REI_SW_EVENT` + +事件名由 SW 在每次广播时打在 `e.data.event` 上: + +| 常量 | 字符串值 | 触发条件 | +|------|---------|---------| +| `REI_SW_EVENT.CONTENT_RECEIVED` | `'rei-amsg-content-received'` | `payload.messageKind === 'content'` | +| `REI_SW_EVENT.REASONING_RECEIVED` | `'rei-amsg-reasoning-received'` | `payload.messageKind === 'reasoning'` | +| `REI_SW_EVENT.TOOL_REQUEST_RECEIVED` | `'rei-amsg-tool-request-received'` | `payload.messageKind === 'tool_request'` | +| `REI_SW_EVENT.ERROR_RECEIVED` | `'rei-amsg-error-received'` | `payload.messageKind === 'error'` | +| `REI_SW_EVENT.UNKNOWN_RECEIVED` | `'rei-amsg-unknown-received'` | 缺 `messageKind`(2.0.x 老 payload / blob envelope) | + +### 客户端订阅示例 + +```js +navigator.serviceWorker.addEventListener('message', (e) => { + if (e.data?.type !== 'REI_AMSG_PUSH') return; + switch (e.data.event) { + case 'rei-amsg-content-received': /* 渲染 app 内消息 */ break; + case 'rei-amsg-reasoning-received': /* 渲染思考中 UI */ break; + case 'rei-amsg-tool-request-received': /* 弹出工具执行确认 */ break; + case 'rei-amsg-error-received': /* 显示错误 toast */ break; + case 'rei-amsg-unknown-received': /* 2.0.x 老 payload 的兼容路径 */ break; + } +}); +``` + +### Blob envelope + +当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。 + +### 升级注意事项 + +- 想给 `reasoning` / `tool_request` / `error` 也弹通知的业务:必须自行在 app 内监听上面的 postMessage 事件、调 `Notification` 或 `registration.showNotification`。SW 默认不再为它们弹通知。 +- 客户端代码继续兼容只有 `installReiSW` + `REI_SW_MESSAGE_TYPE`(队列)的 2.0.x 写法——新增导出不破坏既有 API。 +- 想拿到 push 类型相关的 TS 类型:从 `@rei-standard/amsg-shared` 引 `AmsgPush` 等类型(本包通过 JSDoc 引用同一份类型)。 + ## 功能概览 -- 处理 `push` 事件:自动解析 payload 并展示通知 +- 处理 `push` 事件:按 `messageKind` 三轴 schema 分发到客户端 + 仅 `content` 走 `showNotification` - 处理 `message` 事件:支持离线请求入队与主动冲刷队列 - 处理 `sync` 事件:在网络恢复后自动重试队列请求 - 使用 IndexedDB 存储待发送请求,避免页面关闭后丢失 @@ -97,8 +141,18 @@ export async function enqueueRequestToSW(requestPayload) { ## 导出 API(Exports) - `installReiSW` +- `REI_SW_EVENT` — 2.1.0 新增,按 kind 分发的客户端事件名 +- `REI_AMSG_POSTMESSAGE_TYPE` — 2.1.0 新增,SW → client 广播信封的 `type` 字段(恒为 `'REI_AMSG_PUSH'`) - `REI_SW_MESSAGE_TYPE` +`REI_SW_EVENT` 包含(详见上文 v2.1.0 章节): + +- `CONTENT_RECEIVED` +- `REASONING_RECEIVED` +- `TOOL_REQUEST_RECEIVED` +- `ERROR_RECEIVED` +- `UNKNOWN_RECEIVED` + `REI_SW_MESSAGE_TYPE` 包含: - `ENQUEUE_REQUEST` diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index 323c8fd..3cd6f58 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,7 +1,7 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.0.1", - "description": "ReiStandard Active Messaging service worker SDK", + "version": "2.1.0-next.0", + "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", "url": "https://github.com/Tosd0/ReiStandard", @@ -26,11 +26,15 @@ "dist" ], "scripts": { - "build": "tsup" + "build": "tsup", + "test": "node --test test/*.test.mjs" }, "engines": { "node": ">=20" }, + "dependencies": { + "@rei-standard/amsg-shared": "0.1.0-next.0" + }, "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.0.0" diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 2364c17..3d07ab8 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -2,34 +2,79 @@ * ReiStandard Service Worker helpers. * * Drop-in plugin for Service Workers that handles: - * - Basic push payload -> notification rendering - * - Offline request queueing and retry with Background Sync + * - Three-axis `push` payload dispatch — keyed by `payload.messageKind` + * (see `@rei-standard/amsg-shared`). Every push is mirrored to every + * controlled client via `postMessage` under a per-kind event name. + * - Notification rendering for `messageKind: 'content'` (and legacy + * payloads without `messageKind`, for back-compat with 2.0.x + * producers). + * - Offline request queueing and retry with Background Sync. * * Notes: * - This plugin intentionally does not install `notificationclick`. * Main applications can implement their own click navigation logic. + * - `reasoning` / `tool_request` / `error` pushes are dispatched as + * `postMessage` events but **do not** trigger `showNotification` — + * apps render those in-app via the postMessage channel. + * - Blob envelopes (`{ _blob: true, key, url, messageKind? }`) are + * dispatched to clients verbatim. The SW never auto-fetches the + * blob body — that's the client's job. * * Usage (inside your sw.js): - * import { installReiSW, REI_SW_MESSAGE_TYPE } from '@rei-standard/amsg-sw'; + * import { installReiSW, REI_SW_EVENT, REI_SW_MESSAGE_TYPE } from '@rei-standard/amsg-sw'; * installReiSW(self); * * Usage (inside your web app): - * navigator.serviceWorker.controller?.postMessage({ - * type: REI_SW_MESSAGE_TYPE.ENQUEUE_REQUEST, - * request: { - * url: '/api/messages/send', - * method: 'POST', - * headers: { 'Content-Type': 'application/json' }, - * body: { text: 'hello' } + * navigator.serviceWorker.addEventListener('message', (e) => { + * if (e.data?.type !== 'REI_AMSG_PUSH') return; + * switch (e.data.event) { + * case REI_SW_EVENT.CONTENT_RECEIVED: // render in-app message + * case REI_SW_EVENT.REASONING_RECEIVED: // render thinking UI + * case REI_SW_EVENT.TOOL_REQUEST_RECEIVED: // prompt tool exec + * case REI_SW_EVENT.ERROR_RECEIVED: // show error toast + * case REI_SW_EVENT.UNKNOWN_RECEIVED: // legacy 2.0.x payload * } * }); */ +/** + * @typedef {import('@rei-standard/amsg-shared').AmsgPush} AmsgPush + * @typedef {import('@rei-standard/amsg-shared').ContentPush} ContentPush + * @typedef {import('@rei-standard/amsg-shared').ReasoningPush} ReasoningPush + * @typedef {import('@rei-standard/amsg-shared').ToolRequestPush} ToolRequestPush + * @typedef {import('@rei-standard/amsg-shared').ErrorPush} ErrorPush + */ + const REI_SW_DB_NAME = 'rei-sw'; const REI_SW_DB_STORE = 'request-outbox'; const REI_SW_DB_VERSION = 1; const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox'; +/** + * Wire-level message type for SW → client postMessage envelopes. + * Clients filter on `e.data.type === 'REI_AMSG_PUSH'` before reading + * `e.data.event` (which is one of {@link REI_SW_EVENT}'s values). + */ +export const REI_AMSG_POSTMESSAGE_TYPE = 'REI_AMSG_PUSH'; + +/** + * Per-kind event names dispatched to controlled clients. Each push the + * SW receives is mirrored to every window via + * `postMessage({ type: 'REI_AMSG_PUSH', event: , payload })`. + * + * The mapping is keyed by `payload.messageKind`. Legacy payloads (and + * blob envelopes) without a `messageKind` field dispatch as + * {@link REI_SW_EVENT.UNKNOWN_RECEIVED} so apps can still handle 2.0.x + * producers during migration. + */ +export const REI_SW_EVENT = Object.freeze({ + CONTENT_RECEIVED: 'rei-amsg-content-received', + REASONING_RECEIVED: 'rei-amsg-reasoning-received', + TOOL_REQUEST_RECEIVED: 'rei-amsg-tool-request-received', + ERROR_RECEIVED: 'rei-amsg-error-received', + UNKNOWN_RECEIVED: 'rei-amsg-unknown-received' +}); + export const REI_SW_MESSAGE_TYPE = Object.freeze({ ENQUEUE_REQUEST: 'REI_ENQUEUE_REQUEST', FLUSH_QUEUE: 'REI_FLUSH_QUEUE', @@ -56,15 +101,25 @@ export function installReiSW(sw, opts = {}) { const payload = readPushPayload(event); if (!payload) return; - const notification = createNotificationFromPayload(payload, { - defaultIcon, - defaultBadge - }); - if (!notification) return; + const eventName = resolveEventName(payload); + const shouldRenderNotification = isNotificationKind(payload); + + /** @type {Array>} */ + const work = [dispatchPushToClients(sw, eventName, payload)]; + + if (shouldRenderNotification) { + const notification = createNotificationFromPayload(payload, { + defaultIcon, + defaultBadge + }); + if (notification) { + work.push( + sw.registration.showNotification(notification.title, notification.options) + ); + } + } - event.waitUntil( - sw.registration.showNotification(notification.title, notification.options) - ); + event.waitUntil(Promise.all(work)); }); sw.addEventListener('message', (event) => { @@ -89,6 +144,85 @@ export function installReiSW(sw, opts = {}) { }); } +/** + * Map a parsed push payload to its corresponding per-kind event name. + * Falls back to `UNKNOWN_RECEIVED` for legacy 2.0.x payloads and blob + * envelopes without `messageKind`. + * + * @param {Record} payload + * @returns {string} + */ +function resolveEventName(payload) { + const kind = payload && typeof payload === 'object' ? payload.messageKind : undefined; + switch (kind) { + case 'content': + return REI_SW_EVENT.CONTENT_RECEIVED; + case 'reasoning': + return REI_SW_EVENT.REASONING_RECEIVED; + case 'tool_request': + return REI_SW_EVENT.TOOL_REQUEST_RECEIVED; + case 'error': + return REI_SW_EVENT.ERROR_RECEIVED; + default: + return REI_SW_EVENT.UNKNOWN_RECEIVED; + } +} + +/** + * True when the payload should trigger `showNotification`. Only + * `messageKind: 'content'` renders a notification; everything else + * (`reasoning`, `tool_request`, `error`) is dispatched silently so + * apps can render them in-app. + * + * Legacy payloads with no `messageKind` field still render a + * notification — that's the 2.0.x back-compat path. + * + * @param {Record} payload + * @returns {boolean} + */ +function isNotificationKind(payload) { + if (!payload || typeof payload !== 'object') return false; + const kind = payload.messageKind; + if (kind === undefined || kind === null) return true; + return kind === 'content'; +} + +/** + * Broadcast a parsed push payload to every controlled client. Failures + * on individual `postMessage` calls are swallowed — one offline tab + * shouldn't break delivery to the others. The whole broadcast is + * resolved (never rejected) so it can be safely passed to + * `event.waitUntil`. + * + * @param {ServiceWorkerGlobalScope} sw + * @param {string} eventName + * @param {Record} payload + * @returns {Promise} + */ +async function dispatchPushToClients(sw, eventName, payload) { + try { + const clientList = await sw.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + const envelope = { + type: REI_AMSG_POSTMESSAGE_TYPE, + event: eventName, + payload + }; + for (const client of clientList) { + try { + client.postMessage(envelope); + } catch (_postError) { + // Per-client failures must not abort the broadcast. + } + } + } catch (_matchError) { + // No window clients available, or the matchAll call rejected. + // Either way, fail silently — notification rendering still wins. + } +} + function readPushPayload(event) { if (!event.data) return null; diff --git a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs new file mode 100644 index 0000000..5938fe4 --- /dev/null +++ b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs @@ -0,0 +1,294 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + installReiSW, + REI_SW_EVENT, + REI_AMSG_POSTMESSAGE_TYPE +} from '../src/index.js'; + +/** + * Build a minimal `ServiceWorkerGlobalScope` mock that captures: + * - the listeners installed via `addEventListener` + * - every `showNotification` call (title + options) + * - every `postMessage` payload delivered to each fake client + * + * The mock exposes `triggerPush(payload)` which awaits the full + * `event.waitUntil` chain so tests can assert on side effects + * synchronously after the await. + */ +function createSwMock({ clientCount = 1 } = {}) { + /** @type {Map} */ + const listeners = new Map(); + /** @type {Array<{ title: string, options: Record }>} */ + const notifications = []; + /** @type {Array<{ client: number, message: unknown }>} */ + const postedMessages = []; + + const clients = Array.from({ length: clientCount }, (_, index) => ({ + id: `client-${index}`, + postMessage(message) { + postedMessages.push({ client: index, message }); + } + })); + + const sw = { + addEventListener(name, handler) { + listeners.set(name, handler); + }, + registration: { + showNotification(title, options) { + notifications.push({ title, options: options || {} }); + return Promise.resolve(); + }, + // No `sync` manager — installReiSW must tolerate that. + }, + clients: { + async matchAll(query) { + // Echo the query so tests can verify it if they care. + sw.clients._lastQuery = query; + return clients; + } + } + }; + + async function triggerPush(payload) { + const pushHandler = listeners.get('push'); + if (!pushHandler) throw new Error('push handler was never registered'); + + /** @type {Array>} */ + const pending = []; + const fakeEvent = { + data: { + json: () => payload + }, + waitUntil(work) { + pending.push(Promise.resolve(work)); + } + }; + pushHandler(fakeEvent); + await Promise.all(pending); + } + + return { sw, listeners, notifications, postedMessages, triggerPush }; +} + +const COMMON = Object.freeze({ + messageType: 'instant', + source: 'instant', + messageId: 'msg_test_0', + sessionId: 'sess_test_0', + timestamp: '2026-05-19T00:00:00.000Z' +}); + +test('installReiSW registers the push listener', () => { + const { sw, listeners } = createSwMock(); + installReiSW(sw); + assert.equal(typeof listeners.get('push'), 'function'); + assert.equal(typeof listeners.get('message'), 'function'); + assert.equal(typeof listeners.get('sync'), 'function'); +}); + +test('content push triggers showNotification AND postMessage with CONTENT_RECEIVED', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + const payload = { + ...COMMON, + messageKind: 'content', + message: 'Hello!', + title: 'Rei', + contactName: 'Rei' + }; + await triggerPush(payload); + + assert.equal(notifications.length, 1, 'one notification rendered'); + assert.equal(notifications[0].title, 'Rei'); + assert.equal(notifications[0].options.body, 'Hello!'); + + assert.equal(postedMessages.length, 1, 'one client received exactly one message'); + assert.deepEqual(postedMessages[0].message, { + type: REI_AMSG_POSTMESSAGE_TYPE, + event: REI_SW_EVENT.CONTENT_RECEIVED, + payload + }); +}); + +test('content push broadcasts to every controlled client', async () => { + const { sw, postedMessages, triggerPush } = createSwMock({ clientCount: 3 }); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'content', + message: 'fanout test' + }); + + assert.equal(postedMessages.length, 3, 'every client got the message'); + for (const { message } of postedMessages) { + assert.equal(message.type, REI_AMSG_POSTMESSAGE_TYPE); + assert.equal(message.event, REI_SW_EVENT.CONTENT_RECEIVED); + } +}); + +test('reasoning push dispatches REASONING_RECEIVED but does NOT call showNotification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + const payload = { + ...COMMON, + messageKind: 'reasoning', + reasoningContent: 'thinking out loud…' + }; + await triggerPush(payload); + + assert.equal(notifications.length, 0, 'reasoning kind must not render a notification'); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.REASONING_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, payload); +}); + +test('tool_request push dispatches TOOL_REQUEST_RECEIVED with no notification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + const payload = { + ...COMMON, + messageKind: 'tool_request', + toolCalls: [{ id: 'call_0', type: 'function', function: { name: 'noop', arguments: '{}' } }] + }; + await triggerPush(payload); + + assert.equal(notifications.length, 0); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.TOOL_REQUEST_RECEIVED); +}); + +test('error push dispatches ERROR_RECEIVED with no notification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + const payload = { + ...COMMON, + messageKind: 'error', + code: 'HOOK_THREW', + message: 'onLLMOutput threw something' + }; + await triggerPush(payload); + + assert.equal(notifications.length, 0); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.ERROR_RECEIVED); + assert.equal(postedMessages[0].message.payload.code, 'HOOK_THREW'); +}); + +test('legacy payload without messageKind dispatches UNKNOWN_RECEIVED AND renders a notification (back-compat)', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + // Mimic a 2.0.x payload — no `messageKind`, no `source` discriminator. + const payload = { + title: 'Old style', + body: 'A legacy notification body', + messageId: 'legacy_msg_0' + }; + await triggerPush(payload); + + assert.equal(notifications.length, 1, 'legacy payloads must still render a notification'); + assert.equal(notifications[0].title, 'Old style'); + assert.equal(notifications[0].options.body, 'A legacy notification body'); + + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.UNKNOWN_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, payload); +}); + +test('blob envelope with messageKind: "content" dispatches CONTENT_RECEIVED and renders a placeholder notification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + const envelope = { + _blob: true, + key: 'abc-123', + url: 'https://worker.example.com/blob/abc-123', + messageKind: 'content', + type: 'tool-request' // legacy passthrough field — should be ignored by dispatch logic + }; + await triggerPush(envelope); + + assert.equal(notifications.length, 1, 'content-kind blob envelopes render a placeholder notification'); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.CONTENT_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, envelope, 'blob envelope is forwarded verbatim'); +}); + +test('blob envelope with messageKind: "tool_request" dispatches TOOL_REQUEST_RECEIVED with no notification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw); + + const envelope = { + _blob: true, + key: 'xyz-789', + url: 'https://worker.example.com/blob/xyz-789', + messageKind: 'tool_request' + }; + await triggerPush(envelope); + + assert.equal(notifications.length, 0, 'non-content blob envelopes do not render'); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.TOOL_REQUEST_RECEIVED); +}); + +test('clients.matchAll is called with type:"window" and includeUncontrolled:true', async () => { + const { sw, triggerPush } = createSwMock(); + installReiSW(sw); + + await triggerPush({ ...COMMON, messageKind: 'content', message: 'x' }); + + assert.deepEqual(sw.clients._lastQuery, { + type: 'window', + includeUncontrolled: true + }); +}); + +test('one client throwing inside postMessage does not block delivery to the others', async () => { + // Manual SW mock — needed because the shared createSwMock() builds + // never-throwing clients. Three clients, the middle one throws. + const listeners = new Map(); + const delivered = []; + const clientFactory = (index) => ({ + id: `client-${index}`, + postMessage(message) { + if (index === 1) throw new Error(`client-${index} is broken`); + delivered.push({ client: index, message }); + }, + }); + const clients = [clientFactory(0), clientFactory(1), clientFactory(2)]; + let notificationCount = 0; + const sw = { + addEventListener(name, handler) { listeners.set(name, handler); }, + registration: { + showNotification() { notificationCount++; return Promise.resolve(); }, + }, + clients: { async matchAll() { return clients; } }, + }; + + installReiSW(sw); + + const pending = []; + const fakeEvent = { + data: { json: () => ({ ...COMMON, messageKind: 'content', message: 'survive' }) }, + waitUntil(work) { pending.push(Promise.resolve(work)); }, + }; + listeners.get('push')(fakeEvent); + + // Must not reject — per-client errors are caught and swallowed inside + // the dispatcher; the outer waitUntil chain stays healthy. + await Promise.all(pending); + + // Clients 0 and 2 still got the message; client 1's throw was contained. + assert.equal(delivered.length, 2); + assert.deepEqual(delivered.map((d) => d.client).sort(), [0, 2]); + // Notification rendering ran independently of the broken client. + assert.equal(notificationCount, 1); +}); diff --git a/standards/active-messaging-api.md b/standards/active-messaging-api.md index 9917313..fbec0d2 100644 --- a/standards/active-messaging-api.md +++ b/standards/active-messaging-api.md @@ -1,10 +1,12 @@ -# 主动消息 API 技术规范(v2.3) +# 主动消息 API 技术规范(v2.4) > 状态:当前生效(Active) > -> 版本日期:2026-05-18 +> 版本日期:2026-05-19 > -> 对齐实现:`@rei-standard/amsg-server` 2.3.2、`@rei-standard/amsg-instant` 0.6.1、`@rei-standard/amsg-client` 2.2.3、`@rei-standard/amsg-sw` 2.0.1。 +> 对齐实现(**prerelease**,仓库 `publish-workspaces.mjs` 自动按 prerelease 版本号路由到 `next` dist-tag,不进 `latest`):`@rei-standard/amsg-shared` 0.1.0-next.0、`@rei-standard/amsg-server` 2.4.0-next.0、`@rei-standard/amsg-instant` 0.8.0-next.0、`@rei-standard/amsg-client` 2.3.0-next.0、`@rei-standard/amsg-sw` 2.1.0-next.0。安装:`npm install @rei-standard/amsg-shared@next`(其余同理)。规范条款在 prerelease 期不再改,`next` 窗口是给下游集成方端到端验证用的;契约通过后会发对应正式 minor(去掉 `-next.N` 后缀)。 +> +> 本轮是一次跨包协调的 minor 升级:push wire shape 统一到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器),同时移除旧的 `{ type: 'error', code: '...' }` 错误信封。包间依赖一律使用精确版本(不带 `^`),所有 `dependencies` 字段都钉死在对应的 `*-next.0`。 ## 1. 目标与范围 @@ -35,6 +37,7 @@ - `messages` 数组提示词(互斥替代 `completePrompt`),见 §6.1。`amsg-server` 2.2.0+ 与 `amsg-instant` 0.5.0+ 实装。 - `splitPattern` 自定义分句正则,见 §6.1。`amsg-server` 2.3.0+ 与 `amsg-instant` 0.6.0+ 实装。 - `avatarUrl` 严格校验(拒 `data:` URI、限长度 ≤ 2048),见 §6.2。`amsg-server` 2.3.1+、`amsg-instant` 0.6.1+、`amsg-client` 2.2.3+ 实装。 +- **三轴 push schema 统一**(`messageKind` 判别联合 + 自动 `ReasoningPush`),见 §6.3 / §6.4。`@rei-standard/amsg-shared` 0.1.0-next.0、`amsg-server` 2.4.0-next.0、`amsg-instant` 0.8.0-next.0、`amsg-sw` 2.1.0-next.0、`amsg-client` 2.3.0-next.0 协同实装(`next` dist-tag 预发布)。旧 `{ type: 'error', code: '...' }` 错误信封同步移除。 ## 3. 角色与职责 @@ -214,6 +217,103 @@ export const config = { 预校验工具:`validateAvatarUrl(value)`(`amsg-server` 与 `amsg-instant` 同步导出)。 +### 6.3 推送 wire shape:三轴判别联合 + +自 v2.4 起,所有 amsg 包推出的 Web Push payload 统一遵循 `@rei-standard/amsg-shared` 定义的 `AmsgPush` 判别联合。每条推送由三个**正交**的维度描述: + +| 轴 | 字段 | 取值 | 由谁定 | +|---|---|---|---| +| Dispatch | `messageType` | `instant` / `fixed` / `prompted` / `auto` | 包(固定枚举) | +| Business | `messageSubtype` | 任意字符串 | 调用方(自由命名) | +| Content | `messageKind` | `content` / `reasoning` / `tool_request` / `error` | 包(固定枚举) | + +外加 `source: 'instant' | 'scheduled'` —— 路由来源(`amsg-instant` 输出恒为 `'instant'`;`amsg-server` 任何输出恒为 `'scheduled'`)。`messageType: 'instant'` 必配 `source: 'instant'`;其余三种 `messageType` 必配 `source: 'scheduled'`。 + +`messageKind` 是**字面量类型判别器**:TS 端 `switch (push.messageKind)` 即可窄化到具体子类型;JS 端用 `isContentPush` / `isReasoningPush` / `isToolRequestPush` / `isErrorPush` 守卫函数。 + +#### 6.3.1 所有 push 共有字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `messageKind` | `'content' \| 'reasoning' \| 'tool_request' \| 'error'` | 判别器 | +| `messageType` | `'instant' \| 'fixed' \| 'prompted' \| 'auto'` | Dispatch 轴 | +| `source` | `'instant' \| 'scheduled'` | 路由来源 | +| `messageId` | `string` | 每条推送唯一,格式由 producer 自定 | +| `sessionId` | `string` | **同一 LLM 轮次内共享**(含自动发出的 ReasoningPush + 后续 ContentPush burst);agentic-loop 跨 iteration 复用同一 id | +| `timestamp` | `string` (ISO 8601) | producer 端时钟 | +| `messageSubtype` | `string?` | 业务命名空间,producer 默认填 `'chat'` | +| `metadata` | `object?` | **调用方透传**;包不得写入此字段 | + +#### 6.3.2 `ContentPush`(`messageKind: 'content'`) + +最终面向用户的文本片段。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `message` | `string` | 要展示的句子/段落 | +| `messageIndex` | `number?` | 1-based 段索引,单条不带 | +| `totalMessages` | `number?` | 总段数,单条不带 | +| `title` | `string?` | 通知标题 | +| `contactName` | `string?` | 发送者显示名 | +| `avatarUrl` | `string \| null?` | 仅 `https:`,`data:` 入口拦截 | +| `taskId` | `string \| null?` | 调度任务 ID(仅 server 路径) | + +#### 6.3.3 `ReasoningPush`(`messageKind: 'reasoning'`) + +LLM 思考过程,从 `choices[0].message.reasoning_content` 提升而来。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `reasoningContent` | `string` | 推理文本 | +| `title` | `string?` | | +| `contactName` | `string?` | | +| `avatarUrl` | `string \| null?` | | + +**不带** `messageIndex` / `totalMessages` —— 推理是一轮 LLM 一条,不是分句 burst。这两个字段在类型上故意缺席。 + +#### 6.3.4 `ToolRequestPush`(`messageKind: 'tool_request'`) + +由 agentic-loop 钩子返回 `{ decision: 'tool-request', pushPayload }` 触发。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `toolCalls` | `Array` | OpenAI `choices[0].message.tool_calls` 形状透传 | +| `title` | `string?` | | +| `contactName` | `string?` | | +| `message` | `string?` | 可选人类可读标签 | + +客户端执行工具后通过 `/continue` 恢复。 + +#### 6.3.5 `ErrorPush`(`messageKind: 'error'`) + +生产端诊断错误。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | `string` | producer 定义的稳定码,例如 `HOOK_THREW` / `LOOP_EXCEEDED` | +| `message` | `string` | 人类可读描述 | +| `iteration` | `number?` | agentic-loop 迭代序号(如适用) | + +**v2.4 移除:旧的 `{ type: 'error', code: '...' }` 错误信封**(0.7.x `amsg-instant` 用于 `HOOK_THREW` / `LOOP_EXCEEDED`)已删除。错误推送统一走 `ErrorPush` 形状,顶层不再有 `type: 'error'` 字段——不要在新代码里找这个字段。 + +完整字段表、builders、类型守卫与常量见 [`../packages/rei-standard-amsg/shared/README.md`](../packages/rei-standard-amsg/shared/README.md)。 + +### 6.4 `ReasoningPush` 自动发出不变量 + +LLM 驱动路径(`amsg-instant` 的 legacy 路径与 agentic-loop 钩子路径、`amsg-server` 的 `prompted` / `auto` 路径、`amsg-server` 的 in-server `instant` 路径)在 LLM 返回 `choices[0].message.reasoning_content` 非空时,必须**先**发一条独立的 `ReasoningPush`,**再**发后续的 `ContentPush` burst。两者共享同一个 `sessionId`,客户端可以靠 `sessionId` 把"思考中"UI 拼到真正回复上。 + +具体规则: + +1. **触发条件**:`choices[0].message.reasoning_content` 是非空字符串。空串、`null`、`undefined` 均不触发。 +2. **顺序**:`ReasoningPush` 必须先于该 LLM 轮的任何 `ContentPush` 发出(client 端可据此切换"思考中" UI)。 +3. **`sessionId` 共享**: + - 同一 LLM 轮:`ReasoningPush` + 该轮所有 `ContentPush` 共用一个 `sessionId`。 + - Agentic loop:同一 `/instant` 请求的所有 iteration 共用一个 `sessionId`(不是每轮重新 mint)。 + - `amsg-server` 端:调度行用 `sess_task_`(跨重试稳定);无 task id 时 mint `sess_`。 +4. **钩子路径 opt-out**:`amsg-instant` 的 `createInstantHandler({ autoEmitReasoning: false })` 让钩子作者拿回完整控制权——此时框架不发自动 ReasoningPush,钩子自行读 `ctx.llmResponse.choices[0].message.reasoning_content` 并用 `buildReasoningPush(...)` 自建。legacy(非钩子)路径**始终**自动发,无 opt-out。 +5. **非 LLM 路径不触发**:`fixed` 任务与 `userMessage` 显式路径不产 LLM 响应,自然不发 ReasoningPush。 +6. **`messageIndex` / `totalMessages` 不带**:ReasoningPush 不参与分句 burst 计数;server 端的 `messagesSent` 也只数 ContentPush。 + ## 7. 一体化初始化接口 ### 7.1 请求 From ba003d8b0106e89f74279ebafd9ef487e8163c2a Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 20 May 2026 18:28:29 +0800 Subject: [PATCH 02/33] =?UTF-8?q?fix(amsg):=20avatarUrl=20=E8=BD=AF?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=20=E2=80=94=20=E4=B8=8D=E5=86=8D=20400=20?= =?UTF-8?q?=E6=95=B4=E4=B8=AA=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 server 2.3.1 / instant 0.6.1 / client 2.2.3 引入的"不合法 avatarUrl 直接 400"放宽为"console.warn + 把字段置空 + 继续"。头像是装饰性字段, 单个错误 URL 不应该 fail 整个调度 / 推送。 - server validateScheduleMessagePayload:data:/oversized/malformed 的 avatarUrl 在 payload 上置 null,schedule 继续创建。 - server update-message handler:不合法的 avatarUrl 从 patch 里 delete, 存储里的旧头像保持不变;其它字段照常应用。 - instant validateInstantPayload / validateContinuePayload:同样置 null 策略,/instant 与 /continue 不再因为装饰字段挂掉。 - client _validateAvatarUrl → _sanitizeAvatarUrl:本地不再抛 INVALID_AVATAR_URL_LOCAL,改为 console.warn + 置空 + 照常发请求; updateMessage 路径 delete 字段以匹配服务端 update 语义。 PAYLOAD_TOO_LARGE_LOCAL(3KB 本地体积上限)保留不变,仍是真正的整包过大 信号。validateAvatarUrl 顶层 export 行为不变,仍是纯函数返回错误描述。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/client/src/index.js | 81 ++++++++++--------- .../instant/src/validation.js | 19 +++-- .../instant/test/handler.test.mjs | 61 +++++++------- .../src/server/handlers/update-message.js | 6 +- .../server/src/server/lib/validation.js | 6 +- .../server/test/message-processor.test.mjs | 27 ++++--- .../server/test/sdk.test.mjs | 25 +++--- 7 files changed, 127 insertions(+), 98 deletions(-) diff --git a/packages/rei-standard-amsg/client/src/index.js b/packages/rei-standard-amsg/client/src/index.js index 97341c3..2c8ac8d 100644 --- a/packages/rei-standard-amsg/client/src/index.js +++ b/packages/rei-standard-amsg/client/src/index.js @@ -179,16 +179,16 @@ export class ReiClient { * * The payload is automatically encrypted before transmission. * - * Throws (without a network round-trip): - * - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB, - * or otherwise unacceptable. - * - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB. + * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the + * client soft-strips it on the payload and emits a `console.warn` — the + * schedule still ships, just without an avatar. The only throw left is + * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB. * * @param {Object} payload - Schedule message payload. * @returns {Promise} API response body. */ async scheduleMessage(payload) { - this._validateAvatarUrl(payload && payload.avatarUrl); + this._sanitizeAvatarUrl(payload); const json = JSON.stringify(payload); this._assertPayloadSize(json, 'scheduleMessage'); const encrypted = await this._encrypt(json); @@ -228,10 +228,10 @@ export class ReiClient { * * Routes to `customBaseUrls.instant` if configured, otherwise `baseUrl`. * - * Throws (without a network round-trip): - * - `INVALID_AVATAR_URL_LOCAL` — `avatarUrl` is a `data:` URI, > 2 KB, - * or otherwise unacceptable. - * - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB. + * If `avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), the + * client soft-strips it on the payload and emits a `console.warn` — the + * push still ships, just without an icon. The only throw left is + * `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized payload exceeds 3 KB. * * @param {Object} payload - Instant message payload. * @param {string} [endpointPath] - Path under the resolved base URL. Default '/instant'. @@ -239,7 +239,7 @@ export class ReiClient { * @returns {Promise} `{ success, data?: { messagesSent, sentAt }, error? }` */ async sendInstant(payload, endpointPath = '/instant', opts = {}) { - this._validateAvatarUrl(payload && payload.avatarUrl); + this._sanitizeAvatarUrl(payload); const json = JSON.stringify(payload); this._assertPayloadSize(json, 'sendInstant'); @@ -276,17 +276,23 @@ export class ReiClient { /** * Update an existing scheduled message. * - * Throws (without a network round-trip): - * - `INVALID_AVATAR_URL_LOCAL` — `updates.avatarUrl` is a `data:` URI, - * > 2 KB, or otherwise unacceptable. - * - `PAYLOAD_TOO_LARGE_LOCAL` — JSON-serialized updates exceed 3 KB. + * If `updates.avatarUrl` is unusable (`data:` URI, > 2 KB, or non-string), + * the client soft-strips it from the patch and emits a `console.warn` — + * the rest of the update still applies, and the stored avatar is left + * untouched. The only throw left is `PAYLOAD_TOO_LARGE_LOCAL` — + * JSON-serialized updates exceed 3 KB. * * @param {string} uuid - Task UUID. * @param {Object} updates - Fields to update. * @returns {Promise} */ async updateMessage(uuid, updates) { - this._validateAvatarUrl(updates && updates.avatarUrl); + // Match server-side semantics: a stripped patch shouldn't overwrite the + // stored avatar with `null`. When sanitize fires, remove the field + // entirely so the existing image is preserved. + if (this._sanitizeAvatarUrl(updates)) { + delete updates.avatarUrl; + } const json = JSON.stringify(updates); this._assertPayloadSize(json, 'updateMessage'); const encrypted = await this._encrypt(json); @@ -379,33 +385,36 @@ export class ReiClient { // ─── Local preflight (no network) ──────────────────────────────── /** - * Reject `avatarUrl` values that would 100% fail downstream — `data:` - * URIs (base64 inline image) and anything longer than 2 KB. Mirrors the - * server-side check in `@rei-standard/amsg-instant` / `@rei-standard/amsg-server`; - * intentionally a fast preflight that does not parse the URL (server - * still does that, and `URL` is the bigger of the two costs in browsers). + * Sanitize `avatarUrl` on an outgoing payload. If the value is unusable + * (`data:` URI / oversized / non-string), set the field to `null` on the + * payload, log a `console.warn`, and let the rest of the request go + * through. Avatar is cosmetic — failing the entire schedule / instant + * call over a bad image URL is too punishing. Mirrors the server-side + * soft-strip in `@rei-standard/amsg-server` 2.3.3+ and `@rei-standard/amsg-instant` + * 0.7.1+. See standards §6.2. * * @private - * @param {unknown} value + * @param {object|null|undefined} target - Payload-like object holding `avatarUrl`. + * @returns {boolean} `true` if the field was stripped, `false` otherwise. */ - _validateAvatarUrl(value) { - if (value === undefined || value === null) return; + _sanitizeAvatarUrl(target) { + if (!target || typeof target !== 'object') return false; + const value = target.avatarUrl; + if (value === undefined || value === null) return false; + let reason = null; if (typeof value !== 'string') { - throw makeLocalError('INVALID_AVATAR_URL_LOCAL', 'avatarUrl 必须是字符串'); - } - if (/^data:/i.test(value)) { - throw makeLocalError( - 'INVALID_AVATAR_URL_LOCAL', - '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL' - ); + reason = 'avatarUrl 必须是字符串'; + } else if (/^data:/i.test(value)) { + reason = '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL'; + } else if (value.length > AVATAR_URL_MAX_LENGTH) { + reason = `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`; } - if (value.length > AVATAR_URL_MAX_LENGTH) { - throw makeLocalError( - 'INVALID_AVATAR_URL_LOCAL', - `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`, - { actualLength: value.length, limit: AVATAR_URL_MAX_LENGTH } - ); + if (reason) { + console.warn('[rei-standard-amsg-client] avatarUrl 不合法,已置空:', reason); + target.avatarUrl = null; + return true; } + return false; } /** diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index 0fe6532..957502a 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -283,12 +283,11 @@ export function validateInstantPayload(payload, opts) { const avatarErr = validateAvatarUrl(payload.avatarUrl); if (avatarErr) { - return { - valid: false, - errorCode: 'INVALID_PAYLOAD_FORMAT', - errorMessage: avatarErr, - details: { invalidFields: ['avatarUrl'] } - }; + // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to + // 400 the whole /instant call. Avatar is cosmetic — drop the field, log, + // and let the push go through without an icon. See standards §6.2. + console.warn('[amsg-instant] avatarUrl 不合法,已置空:', avatarErr); + payload.avatarUrl = null; } // messageSubtype is a free-form string tag for SW-side classification. @@ -448,10 +447,10 @@ export function validateContinuePayload(payload, opts) { } const avatarErr = validateAvatarUrl(payload.avatarUrl); if (avatarErr) { - return { - valid: false, errorCode: 'INVALID_PAYLOAD_FORMAT', - errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } - }; + // Soft-strip: same policy as /instant — drop the field, log, continue. + // See standards §6.2. + console.warn('[amsg-instant] /continue avatarUrl 不合法,已置空:', avatarErr); + payload.avatarUrl = null; } return validateHookPathSharedFields(payload, opts) || { valid: true }; diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index 8a6fb84..9d95a40 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -222,10 +222,12 @@ describe('validateInstantPayload', () => { assert.match(r.errorMessage, /splitPattern\[1\]/); }); - // ── avatarUrl (0.6.1) ────────────────────────────────────────────── + // ── avatarUrl (0.6.1 → soft-strip in 0.7.1) ────────────────────────── it('accepts a normal https avatarUrl', () => { - const r = validateInstantPayload(makeValidPayload({ avatarUrl: 'https://example.com/a.png' })); + const payload = makeValidPayload({ avatarUrl: 'https://example.com/a.png' }); + const r = validateInstantPayload(payload); assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, 'https://example.com/a.png'); }); it('treats avatarUrl=null / undefined as absent', () => { @@ -233,48 +235,51 @@ describe('validateInstantPayload', () => { assert.equal(validateInstantPayload(makeValidPayload({ avatarUrl: undefined })).valid, true); }); - it('rejects avatarUrl that is a data: URI', () => { - const r = validateInstantPayload(makeValidPayload({ + it('soft-strips data: avatarUrl and continues', () => { + const payload = makeValidPayload({ avatarUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQ', - })); - assert.equal(r.valid, false); - assert.equal(r.errorCode, 'INVALID_PAYLOAD_FORMAT'); - assert.match(r.errorMessage, /data:/); - assert.deepEqual(r.details.invalidFields, ['avatarUrl']); + }); + const r = validateInstantPayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); - it('rejects avatarUrl with uppercase DATA: prefix (case-insensitive)', () => { - const r = validateInstantPayload(makeValidPayload({ - avatarUrl: 'DATA:image/png;base64,xxx', - })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /data:/i); + it('soft-strips uppercase DATA: avatarUrl (case-insensitive)', () => { + const payload = makeValidPayload({ avatarUrl: 'DATA:image/png;base64,xxx' }); + const r = validateInstantPayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); - it('rejects avatarUrl longer than 2048 chars', () => { + it('soft-strips avatarUrl longer than 2048 chars', () => { const longUrl = 'https://example.com/' + 'a'.repeat(2048); - const r = validateInstantPayload(makeValidPayload({ avatarUrl: longUrl })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /2048/); + const payload = makeValidPayload({ avatarUrl: longUrl }); + const r = validateInstantPayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); it('accepts avatarUrl exactly at the 2048 char limit', () => { const url = 'https://x/' + 'a'.repeat(2048 - 'https://x/'.length); assert.equal(url.length, 2048); - const r = validateInstantPayload(makeValidPayload({ avatarUrl: url })); + const payload = makeValidPayload({ avatarUrl: url }); + const r = validateInstantPayload(payload); assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, url); }); - it('rejects avatarUrl that is not a string', () => { - const r = validateInstantPayload(makeValidPayload({ avatarUrl: 123 })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /字符串/); + it('soft-strips avatarUrl that is not a string', () => { + const payload = makeValidPayload({ avatarUrl: 123 }); + const r = validateInstantPayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); - it('rejects avatarUrl that is not a valid URL', () => { - const r = validateInstantPayload(makeValidPayload({ avatarUrl: 'not a url' })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /URL/); + it('soft-strips avatarUrl that is not a valid URL', () => { + const payload = makeValidPayload({ avatarUrl: 'not a url' }); + const r = validateInstantPayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); }); diff --git a/packages/rei-standard-amsg/server/src/server/handlers/update-message.js b/packages/rei-standard-amsg/server/src/server/handlers/update-message.js index 12363d6..df38441 100644 --- a/packages/rei-standard-amsg/server/src/server/handlers/update-message.js +++ b/packages/rei-standard-amsg/server/src/server/handlers/update-message.js @@ -113,7 +113,11 @@ export function createUpdateMessageHandler(ctx) { if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) { const avatarErr = validateAvatarUrl(updates.avatarUrl); if (avatarErr) { - return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: avatarErr, details: { invalidFields: ['avatarUrl'] } } } }; + // Soft-strip: drop the bad avatarUrl from the patch (keeps the + // existing stored avatar untouched) and continue applying the rest + // of the update. See standards §6.2. + console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr); + delete updates.avatarUrl; } } diff --git a/packages/rei-standard-amsg/server/src/server/lib/validation.js b/packages/rei-standard-amsg/server/src/server/lib/validation.js index 41a8fd6..b0392ad 100644 --- a/packages/rei-standard-amsg/server/src/server/lib/validation.js +++ b/packages/rei-standard-amsg/server/src/server/lib/validation.js @@ -261,7 +261,11 @@ export function validateScheduleMessagePayload(payload) { const avatarErr = validateAvatarUrl(payload.avatarUrl); if (avatarErr) { - return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } }; + // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to + // 400 the whole schedule. Avatar is cosmetic — drop the field, log, and + // let the rest of the task ship. See standards §6.2. + console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr); + payload.avatarUrl = null; } if (payload.uuid && !isValidUUID(payload.uuid)) { return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } }; diff --git a/packages/rei-standard-amsg/server/test/message-processor.test.mjs b/packages/rei-standard-amsg/server/test/message-processor.test.mjs index 25a16ac..586e83a 100644 --- a/packages/rei-standard-amsg/server/test/message-processor.test.mjs +++ b/packages/rei-standard-amsg/server/test/message-processor.test.mjs @@ -903,25 +903,26 @@ describe('avatarUrl validation', () => { assert.match(validateAvatarUrl('not a url'), /URL/); }); - it('schedule payload: rejects data: avatarUrl with INVALID_PARAMETERS', () => { - const r = validateScheduleMessagePayload(basePayload({ avatarUrl: 'data:image/png;base64,xxx' })); - assert.equal(r.valid, false); - assert.equal(r.errorCode, 'INVALID_PARAMETERS'); - assert.deepEqual(r.details.invalidFields, ['avatarUrl']); - assert.match(r.errorMessage, /data:/); + it('schedule payload: soft-strips data: avatarUrl (v2.3.3+)', () => { + const payload = basePayload({ avatarUrl: 'data:image/png;base64,xxx' }); + const r = validateScheduleMessagePayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); - it('schedule payload: rejects oversized avatarUrl', () => { - const r = validateScheduleMessagePayload(basePayload({ + it('schedule payload: soft-strips oversized avatarUrl', () => { + const payload = basePayload({ avatarUrl: 'https://example.com/' + 'a'.repeat(2048), - })); - assert.equal(r.valid, false); - assert.equal(r.errorCode, 'INVALID_PARAMETERS'); - assert.match(r.errorMessage, /2048/); + }); + const r = validateScheduleMessagePayload(payload); + assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, null); }); it('schedule payload: accepts a normal https avatarUrl', () => { - const r = validateScheduleMessagePayload(basePayload({ avatarUrl: 'https://example.com/a.png' })); + const payload = basePayload({ avatarUrl: 'https://example.com/a.png' }); + const r = validateScheduleMessagePayload(payload); assert.equal(r.valid, true); + assert.equal(payload.avatarUrl, 'https://example.com/a.png'); }); }); diff --git a/packages/rei-standard-amsg/server/test/sdk.test.mjs b/packages/rei-standard-amsg/server/test/sdk.test.mjs index 84508c9..415b24a 100644 --- a/packages/rei-standard-amsg/server/test/sdk.test.mjs +++ b/packages/rei-standard-amsg/server/test/sdk.test.mjs @@ -664,9 +664,9 @@ describe('update-message splitPattern round-trip', () => { assert.deepEqual(result.body.error.details.invalidFields, ['splitPattern']); }); - it('PUT rejects data: avatarUrl with INVALID_UPDATE_DATA (v2.3.1)', async () => { + it('PUT soft-strips data: avatarUrl, preserves stored avatar (v2.3.3+)', async () => { globalThis.__REI_BLOB_STORE__ = createInMemoryBlobStore(); - const { server } = await buildServerAndAdapter(); + const { server, adapter } = await buildServerAndAdapter(); const { tenantToken, userKey } = await bootstrapTenant(server); const taskUuid = '33333333-2222-4333-8444-777777777777'; @@ -677,7 +677,8 @@ describe('update-message splitPattern round-trip', () => { messageType: 'fixed', firstSendTime: new Date(Date.now() + 60_000).toISOString(), pushSubscription: { endpoint: 'https://push.example.com' }, - userMessage: 'hi' + userMessage: 'hi', + avatarUrl: 'https://example.com/original.png' }, userKey ); @@ -691,7 +692,12 @@ describe('update-message splitPattern round-trip', () => { scheduleBody ); - const badBody = encryptPayload({ avatarUrl: 'data:image/png;base64,xxx' }, userKey); + // Update with a bad avatarUrl AND a valid userMessage change. The bad + // avatar is silently dropped; the other field still gets written. + const patchBody = encryptPayload( + { avatarUrl: 'data:image/png;base64,xxx', userMessage: 'updated' }, + userKey + ); const result = await server.handlers.updateMessage.PUT( `/api/v1/update-message?id=${taskUuid}`, { @@ -700,12 +706,13 @@ describe('update-message splitPattern round-trip', () => { 'x-payload-encrypted': 'true', 'x-encryption-version': '1' }, - badBody + patchBody ); - assert.equal(result.status, 400); - assert.equal(result.body.error.code, 'INVALID_UPDATE_DATA'); - assert.deepEqual(result.body.error.details.invalidFields, ['avatarUrl']); - assert.match(result.body.error.message, /data:/); + assert.equal(result.status, 200); + const after = await adapter.getTaskByUuid(taskUuid, TEST_USER_ID); + const afterData = JSON.parse(decryptFromStorage(after.encrypted_payload, userKey)); + assert.equal(afterData.avatarUrl, 'https://example.com/original.png', 'bad avatar stripped → original preserved'); + assert.equal(afterData.userMessage, 'updated', 'sibling field still applied'); }); }); From 261ac0ee1c222cd9caf0aac13f858e7056bf5b56 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 20 May 2026 18:28:37 +0800 Subject: [PATCH 03/33] =?UTF-8?q?docs:=20avatarUrl=20=E8=BD=AF=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=E7=AD=96=E7=95=A5=E5=90=8C=E6=AD=A5=E5=88=B0=20=C2=A7?= =?UTF-8?q?6.2=20/=20READMEs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit standards 文档把 §6.2 从"严格 400"重写成"console.warn + 置空 + 继续", §1 增量摘要与 §11 版本史一起更新;各包 README 同步调用方契约(client README 的"本地预校验"小节改成"本地软清空",去掉 INVALID_AVATAR_URL_LOCAL 的 try/catch 示例,只留 PAYLOAD_TOO_LARGE_LOCAL)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/README.md | 2 +- packages/rei-standard-amsg/client/README.md | 22 +++++++++---------- packages/rei-standard-amsg/instant/README.md | 2 +- packages/rei-standard-amsg/server/README.md | 2 +- standards/active-messaging-api.md | 23 ++++++++++++-------- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/examples/README.md b/examples/README.md index 25068dc..795e137 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,7 +10,7 @@ > |---|---|---| > | OpenAI 格式 `messages` 数组(system / 多轮 / tool role)+ `temperature` 透传 | server 2.2.0 · instant 0.5.0 · client 2.2.1 | `lib/message-processor.js` 的 `buildAiRequestBody` 把 prompt 硬包成单条 user 消息 | > | `splitPattern` 自定义分句正则(`string \| string[]`,级联) | server 2.3.0 · instant 0.6.0 | 仍硬编码 `/([。!?!?]+)/` 分句 | -> | `avatarUrl` 严格校验(拒 `data:` URI、长度 ≤ 2048) | server 2.3.1 · instant 0.6.1 · client 2.2.3(本地预校验) | 只检 `new URL(...)` 能 parse;`data:` base64 头像会进库再触发下游 413 | +> | `avatarUrl` 软清空(不合法值 `console.warn` + 置空,不再 400 整个任务) | server 2.3.3 / 2.4.0-next.1 · instant 0.7.1 / 0.8.0-next.1 · client 2.2.4 / 2.3.0-next.1 | 只检 `new URL(...)` 能 parse;`data:` base64 头像会进库再触发下游 413 | > > 新接入请直接用 SDK 包(`@rei-standard/amsg-server` / `amsg-instant` / `amsg-client`),行为已按规范对齐到字节级。这份示例的文档与代码后续会同步更新。 diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index c5a03f3..33a1056 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -180,24 +180,24 @@ await client.sendInstant({ - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。 - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。 -### 本地预校验:`avatarUrl` 与 payload 体积(2.2.3+) +### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0-next.1+) -`scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项预检,避免一次远端往返才拿到 `413` 或 Web Push 4KB 上限报错: +`scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护: -| 触发条件 | 抛出 `Error.code` | 触发原因(背景说明,不在 message 里) | +| 触发条件 | 处理方式 | 触发原因(背景说明,不在 message 里) | | --- | --- | --- | -| `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `INVALID_AVATAR_URL_LOCAL` | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 | -| `payload.avatarUrl` 长度 > 2048 字符 | `INVALID_AVATAR_URL_LOCAL` | 同上。建议用 CDN 缩略图 URL。 | -| `payload.avatarUrl` 不是字符串 | `INVALID_AVATAR_URL_LOCAL` | 类型错误。 | -| `JSON.stringify(payload)` UTF-8 字节数 > 3072 | `PAYLOAD_TOO_LARGE_LOCAL` | 远端网关 / Web Push 4KB 硬上限的本地兜底。错误对象带 `.details = { method, actualBytes, limitBytes }` 方便定位。 | +| `payload.avatarUrl` 以 `data:` 开头(含 `data:image/...;base64,...`) | `console.warn` + 在 payload 上把 `avatarUrl` 置为 `null`,请求照发(`updateMessage` 从 patch 里删除该字段,保留服务端原头像) | base64 内嵌头像把单个 push payload 撑到几十 KB,远端 Web Push 服务直接返回 4KB 超限 / 网关 `413`。 | +| `payload.avatarUrl` 长度 > 2048 字符 | 同上 | 同上。建议用 CDN 缩略图 URL。 | +| `payload.avatarUrl` 不是字符串 | 同上 | 类型错误。 | +| `JSON.stringify(payload)` UTF-8 字节数 > 3072 | 抛出 `Error.code === 'PAYLOAD_TOO_LARGE_LOCAL'`,错误对象带 `.details = { method, actualBytes, limitBytes }` | 远端网关 / Web Push 4KB 硬上限的本地兜底。 | + +头像是装饰字段,单个不合规 URL 不再让整次调度 / 推送挂掉;想拦到错误请监听 `console.warn`,或在调用前自己用 `validateAvatarUrl` 预检(server / instant 包都有导出)。`PAYLOAD_TOO_LARGE_LOCAL` 仍然是真正的"整包过大"信号,照常用 try/catch 捕获: ```js try { await client.sendInstant(payload); } catch (err) { - if (err.code === 'INVALID_AVATAR_URL_LOCAL') { - // err.message 形如「头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL」 - } else if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') { + if (err.code === 'PAYLOAD_TOO_LARGE_LOCAL') { // err.details = { method: 'sendInstant', actualBytes: 8732, limitBytes: 3072 } } else { throw err; @@ -205,7 +205,7 @@ try { } ``` -服务端(`@rei-standard/amsg-instant` 0.6.1+ / `@rei-standard/amsg-server` 2.3.1+)有等价的二道防线,业务可以放心依赖 client 这一道做 UX 提示。 +服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0-next.1+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0-next.1+)有同样的软清空二道防线,client 这一道主要省一次远端往返。 ## 导出 API(Exports) diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index f5a10f6..fb81ade 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -113,7 +113,7 @@ Content-Type: application/json ```ts { contactName: string; - avatarUrl?: string | null; // 0.6.1+:拒 data: URI / 长度 > 2KB(base64 头像会触发 413 / Web Push 4KB 上限) + avatarUrl?: string | null; // 0.7.1+ / 0.8.0-next.1+:不合法值(data: URI / 长度 > 2KB / 非字符串)软清空 + console.warn,整次推送不再 fail // === 提示词,二选一恰好一个(0.5.0+)=== completePrompt?: string; // 简单推送场景:单 user 消息 diff --git a/packages/rei-standard-amsg/server/README.md b/packages/rei-standard-amsg/server/README.md index 162d93c..51686da 100644 --- a/packages/rei-standard-amsg/server/README.md +++ b/packages/rei-standard-amsg/server/README.md @@ -9,7 +9,7 @@ - 业务端点统一使用 `Authorization: Bearer ` - `send-notifications` 支持 `cronToken`(Header 或 query token) -2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 严格校验)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-严格校验);行为已对齐 `amsg-instant`,向后兼容。 +2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略);行为已对齐 `amsg-instant`,向后兼容。 ## 安装 diff --git a/standards/active-messaging-api.md b/standards/active-messaging-api.md index fbec0d2..7d8b3aa 100644 --- a/standards/active-messaging-api.md +++ b/standards/active-messaging-api.md @@ -36,7 +36,7 @@ - `messages` 数组提示词(互斥替代 `completePrompt`),见 §6.1。`amsg-server` 2.2.0+ 与 `amsg-instant` 0.5.0+ 实装。 - `splitPattern` 自定义分句正则,见 §6.1。`amsg-server` 2.3.0+ 与 `amsg-instant` 0.6.0+ 实装。 -- `avatarUrl` 严格校验(拒 `data:` URI、限长度 ≤ 2048),见 §6.2。`amsg-server` 2.3.1+、`amsg-instant` 0.6.1+、`amsg-client` 2.2.3+ 实装。 +- `avatarUrl` 软清空策略(不合法值仅 `console.warn` 并置空,不再 400 整个任务),见 §6.2。`amsg-server` 2.3.3+ / 2.4.0-next.1+、`amsg-instant` 0.7.1+ / 0.8.0-next.1+、`amsg-client` 2.2.4+ / 2.3.0-next.1+ 实装;2.3.1 ~ 2.3.2 / 0.6.1 ~ 0.7.0 / 2.2.3 ~ 2.3.0-next.0 走老版"严格 400"。 - **三轴 push schema 统一**(`messageKind` 判别联合 + 自动 `ReasoningPush`),见 §6.3 / §6.4。`@rei-standard/amsg-shared` 0.1.0-next.0、`amsg-server` 2.4.0-next.0、`amsg-instant` 0.8.0-next.0、`amsg-sw` 2.1.0-next.0、`amsg-client` 2.3.0-next.0 协同实装(`next` dist-tag 预发布)。旧 `{ type: 'error', code: '...' }` 错误信封同步移除。 ## 3. 角色与职责 @@ -198,24 +198,28 @@ export const config = { `amsg-server` 与 `amsg-instant` 两端独立实现但行为字节级一致;预校验工具:`validateLlmMessagesArray(messages)`、`validateSplitPattern(value)`。 -### 6.2 `avatarUrl` 严格校验 +### 6.2 `avatarUrl` 软清空策略 `avatarUrl` 字段(`schedule-message` / `update-message` / `amsg-instant` payload,可选)的合法规则: - 必须是字符串,且 `new URL(...)` 能解析。 -- **拒** `data:` 开头的 URI(不区分大小写)—— base64 内嵌图片会把 push payload 撑到几十 KB,触发下游 Web Push 4KB 硬上限或网关 `413 Payload Too Large`。 -- **拒** 长度 > 2048 字符的 URL。 +- **不接受** `data:` 开头的 URI(不区分大小写)—— base64 内嵌图片会把 push payload 撑到几十 KB,触发下游 Web Push 4KB 硬上限或网关 `413 Payload Too Large`。 +- **不接受** 长度 > 2048 字符的 URL。 - `undefined` / `null` 视为"未传",零行为变化。 -违规返回: +**处理方式(amsg-server 2.3.3+ / 2.4.0-next.1+,amsg-instant 0.7.1+ / 0.8.0-next.1+,amsg-client 2.2.4+ / 2.3.0-next.1+)**:头像是装饰性字段,单独一个不合法 URL 不应该把整条推送 fail 掉。所以服务端 / 客户端遇到上面任何不合法情形,**不返回 4xx**,而是: +1. 把 `avatarUrl` 在 payload 上**置为 `null`**(schedule / instant 路径);`update-message` 路径则**从 patch 里删掉**该字段,已存储的旧头像保持不变。 +2. 在控制台 `console.warn` 出原因(含建议,如"请改为公网可访问的 https:// 图片 URL")。 +3. 继续处理 payload 其它字段。 + +老版本(`amsg-server` 2.3.1 ~ 2.3.2 / 2.4.0-next.0、`amsg-instant` 0.6.1 ~ 0.7.0 / 0.8.0-next.0、`amsg-client` 2.2.3 / 2.3.0-next.0)走严格 400: - `amsg-server.schedule-message` → `400 INVALID_PARAMETERS` - `amsg-server.update-message` → `400 INVALID_UPDATE_DATA` - `amsg-instant` → `400 INVALID_PAYLOAD_FORMAT` +- `amsg-client` 本地预校验抛 `Error` 的 `.code === 'INVALID_AVATAR_URL_LOCAL'`(2.2.4+ 已移除,改为本地 `console.warn` + 置空)。 -错误信息须明示原因和建议(如"请改为公网可访问的 https:// 图片 URL"或"建议使用 CDN 缩略图")。客户端 SDK 同时做本地预校验(`amsg-client` 2.2.3+),抛出 `Error` 的 `.code` 为 `INVALID_AVATAR_URL_LOCAL`,避免一次远端往返。 - -预校验工具:`validateAvatarUrl(value)`(`amsg-server` 与 `amsg-instant` 同步导出)。 +预校验工具:`validateAvatarUrl(value)`(`amsg-server` 与 `amsg-instant` 同步导出)—— 返回错误描述字符串或 `null`,**纯函数**,不副作用;上层调用方按软清空策略处理。 ### 6.3 推送 wire shape:三轴判别联合 @@ -456,7 +460,8 @@ v2.x 后续增量(向后兼容,无需迁移): - `messages` 数组(2.2.0+):未使用此字段的调用方零修改。 - `splitPattern`(2.3.0+):未传时走默认正则,老库存任务字段缺失也按默认处理。 -- `avatarUrl` 严格校验(2.3.1+):之前传 `data:` URI 当 avatarUrl 实际上一直推不出来(触发下游 4KB / 413),收紧到入口立即报错而已;从未推成功的调用者无感升级。 +- `avatarUrl` 严格校验(2.3.1 ~ 2.3.2):之前传 `data:` URI 当 avatarUrl 实际上一直推不出来(触发下游 4KB / 413),收紧到入口立即报错而已;从未推成功的调用者无感升级。 +- `avatarUrl` 软清空(server 2.3.3+ / 2.4.0-next.1+,instant 0.7.1+ / 0.8.0-next.1+,client 2.2.4+ / 2.3.0-next.1+):把"严格 400"放宽为"`console.warn` + 置空 + 继续"。整条推送不再因为一个装饰性字段挂掉;之前依赖 400 报错的调用方只需改成观察 `console.warn`。详见 §6.2。 ## 12. 实现一致性要求(DoD) From 31205e4ed4ff1eb574fe9de8ec7ce6eeb52541c5 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 20 May 2026 18:30:22 +0800 Subject: [PATCH 04/33] =?UTF-8?q?fix(sw):=20=E6=A0=87=E9=A2=98=20fallback?= =?UTF-8?q?=20=E8=87=B3=E3=80=8C=E6=9D=A5=E8=87=AA=20{contactName}?= =?UTF-8?q?=E3=80=8D=E2=80=94=20=E6=A8=A1=E4=BB=BF=200.6.x=20=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前 SW createNotificationFromPayload 的标题链是 pushNotification.title || payload.title || 'New notification' custom hook(0.7.x 自定义 envelope)如果忘了塞 title 字段,但塞了 contactName,通知就会掉到 'New notification' 这种英文兜底上。 加一档 contactName 兜底,与 server/instant 默认 envelope 的 title: '来自 ${contactName}' 行为对齐: pushNotification.title || payload.title || (payload.contactName && '来自 ${payload.contactName}') || 'New notification' amsg-sw 2.0.1 → 2.0.2(patch)。 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 2 +- packages/rei-standard-amsg/sw/package.json | 2 +- packages/rei-standard-amsg/sw/src/index.js | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 729cc4c..4c09715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1923,7 +1923,7 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.0", + "version": "2.1.0-next.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.0" diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index 3cd6f58..e8277e7 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.0", + "version": "2.1.0-next.1", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 3d07ab8..8084b27 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -253,7 +253,11 @@ function createNotificationFromPayload(payload, defaults) { ? payload.notification : {}; - const title = pushNotification.title || payload.title || 'New notification'; + const title = + pushNotification.title || + payload.title || + (payload.contactName && `来自 ${payload.contactName}`) || + 'New notification'; const body = pushNotification.body || payload.body || payload.message || ''; const data = payload.data && typeof payload.data === 'object' ? { ...payload.data } From 6926b3db44ace1c670be8ca992017307c8e58102 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 20 May 2026 18:44:54 +0800 Subject: [PATCH 05/33] =?UTF-8?q?chore(amsg):=20bump=20pre-release=20to=20?= =?UTF-8?q?*-next.1=20=E2=80=94=20fold=20avatarUrl=20=E8=BD=AF=E6=B8=85?= =?UTF-8?q?=E7=A9=BA=20+=20SW=20=E6=A0=87=E9=A2=98=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个 amsg next 包同步从 *-next.0 → *-next.1: - @rei-standard/amsg-server 2.4.0-next.0 → 2.4.0-next.1 - @rei-standard/amsg-instant 0.8.0-next.0 → 0.8.0-next.1 - @rei-standard/amsg-client 2.3.0-next.0 → 2.3.0-next.1 - @rei-standard/amsg-sw 2.1.0-next.0 → 2.1.0-next.1 next.0 → next.1 只两件事: 1. avatarUrl 软清空(与 stable 2.3.3 / 0.7.1 / 2.2.4 对齐) 2. SW 标题 fallback 至 「来自 {contactName}」(与 stable 2.0.2 对齐) 三轴 push schema、ReasoningPush、shared 包都**完全不动**; @rei-standard/amsg-shared 也保持 0.1.0-next.0 不需要 bump。 四个包对 shared 的 pinned-exact 依赖都不变。 package-lock.json 同步到新版号。 Tests: 226/226 pass (server 74 / instant 121 / sw 11 / shared 14 / client 6). Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 6 ++--- .../rei-standard-amsg/client/CHANGELOG.md | 6 +++++ .../rei-standard-amsg/client/package.json | 2 +- .../rei-standard-amsg/instant/CHANGELOG.md | 6 +++++ .../rei-standard-amsg/instant/package.json | 2 +- .../rei-standard-amsg/server/CHANGELOG.md | 6 +++++ .../rei-standard-amsg/server/package.json | 2 +- packages/rei-standard-amsg/sw/CHANGELOG.md | 23 +++++++++++++++++++ 8 files changed, 47 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c09715..c73609e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1850,7 +1850,7 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.3.0-next.0", + "version": "2.3.0-next.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.0" @@ -1865,7 +1865,7 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.0", + "version": "0.8.0-next.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.0" @@ -1880,7 +1880,7 @@ }, "packages/rei-standard-amsg/server": { "name": "@rei-standard/amsg-server", - "version": "2.4.0-next.0", + "version": "2.4.0-next.1", "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index c0ee1c6..f0e864e 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — @rei-standard/amsg-client +## 2.3.0-next.1 — avatarUrl 本地软清空 (pre-release) + +Cherry-pick stable `2.2.4` 的本地 `avatarUrl` 软清空到 next 预发布线。`scheduleMessage` / `sendInstant` / `updateMessage` 不合法的 `avatarUrl`(`data:` URI / 长度 > 2048 / 非字符串)改为 `console.warn` + 在 payload 上置 `null`(`updateMessage` 路径走 `delete` 以保留服务端原头像),请求继续发送。`Error.code === 'INVALID_AVATAR_URL_LOCAL'` 已移除;`PAYLOAD_TOO_LARGE_LOCAL`(3KB 体积上限)保留不变。详见 `2.2.4` stable 条目;与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 + +`next.0` → `next.1` 行为变化只此一项;shared push types re-exports 部分**完全不动**。 + ## 2.3.0-next.0 — Shared push types re-exports (pre-release) Published under the `next` dist-tag (repo convention for prereleases). Coordinated with the other amsg sub-packages' `*-next.0` releases. Install with `npm install @rei-standard/amsg-client@next`. Schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index c6653e9..0ab2b75 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-client", - "version": "2.3.0-next.0", + "version": "2.3.0-next.1", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 5c0713a..93a9a84 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.1 — avatarUrl 软清空 (pre-release) + +Cherry-pick stable `0.7.1` 的 `avatarUrl` 软清空策略到 next 预发布线。`/instant` 与 `/continue` 路径不合法的 `avatarUrl`(`data:` URI / 长度 > 2048 / 非字符串 / 不是合法 URL)会在 payload 上**置为 `null`** + `console.warn`,整次推送继续;`INVALID_PAYLOAD_FORMAT` 不再为 `avatarUrl` 触发,其它字段错误码不变。详见 `0.7.1` stable 条目;与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-client` 2.3.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 + +`next.0` → `next.1` 行为变化只此一项;三轴 push schema 部分**完全不动**。 + ## 0.8.0-next.0 — Three-axis push schema + ReasoningPush (pre-release) Published under the `next` dist-tag (repo convention for prereleases). Coordinated with `@rei-standard/amsg-shared@0.1.0-next.0`, `amsg-server@2.4.0-next.0`, `amsg-sw@2.1.0-next.0`, `amsg-client@2.3.0-next.0`. Install with `npm install @rei-standard/amsg-instant@next`. The schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index f7ffd33..d043ef0 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.0", + "version": "0.8.0-next.1", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index ebc0308..924bc70 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — @rei-standard/amsg-server +## 2.4.0-next.1 — avatarUrl 软清空 (pre-release) + +Cherry-pick stable `2.3.3` 的 `avatarUrl` 软清空策略到 next 预发布线。把 2.3.1 引入的"严格 400"放宽为"`console.warn` + 把 `avatarUrl` 置空 + 继续":`schedule-message` 不合法的 `avatarUrl` 在 payload 上置 `null`,`update-message` 把不合法字段从 patch 里 `delete`(旧头像保持不变)。`INVALID_PARAMETERS` / `INVALID_UPDATE_DATA` 不再为 `avatarUrl` 触发,其它字段错误码不变。详见 `2.3.3` stable 条目;与 `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-client` 2.3.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 + +`next.0` → `next.1` 行为变化只此一项;三轴 push schema 部分**完全不动**。 + ## 2.4.0-next.0 — Three-axis push schema + ReasoningPush (pre-release) Published under the `next` dist-tag (repo convention for prereleases). Coordinated with the other amsg sub-packages' `*-next.0` releases. Install with `npm install @rei-standard/amsg-server@next`. Schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 51916a1..645fff9 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-server", - "version": "2.4.0-next.0", + "version": "2.4.0-next.1", "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index 4ad0506..5fb522e 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog — @rei-standard/amsg-sw +## 2.1.0-next.1 — 标题 fallback 至 `来自 {contactName}` (pre-release) + +Cherry-pick stable `2.0.2` 的标题 fallback 修复到 next 预发布线。`createNotificationFromPayload` 的标题链从 + +```js +pushNotification.title || payload.title || 'New notification' +``` + +加一档 `contactName` 兜底,与 server / instant 默认 envelope 的 `title: '来自 ${contactName}'` 行为对齐: + +```js +pushNotification.title + || payload.title + || (payload.contactName && `来自 ${payload.contactName}`) + || 'New notification' +``` + +custom hook(0.7.x / 0.8.0-next.x 自定义 envelope)忘了塞 `title` 但塞了 `contactName` 的情况,通知不再掉到 'New notification' 这种英文兜底上。 + +与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-client` 2.3.0-next.1(avatarUrl 软清空)同步。 + +`next.0` → `next.1` 行为变化只此一项;三轴 push schema 部分**完全不动**。 + ## 2.1.0-next.0 — Three-axis push schema + per-kind client events (pre-release) Published under the `next` dist-tag (repo convention for prereleases). Coordinated with the other amsg sub-packages' `*-next.0` releases. Install with `npm install @rei-standard/amsg-sw@next`. Schema is locked; the next-tag window is for downstream integrators to validate end-to-end before this graduates to `latest`. From e00bf22e4748b69441dd5f1158ea20c689adad7d Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 20 May 2026 23:24:23 +0800 Subject: [PATCH 06/33] chore(amsg): release amsg-shared 0.1.0-next.2 / amsg-instant 0.8.0-next.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared next.2: - ReasoningPush 加四个可选索引字段(messageIndex/totalMessages, chunkIndex/totalChunks),单 chunk 单 segment 时不写到 wire,老 SW 字节级兼容 - 导出 chunkReasoningByUtf8Bytes — UTF-8 codepoint-safe 字节切分 helper instant next.2: - fix: splitPattern 在 hook 模式下重新生效(0.7 的「ignored 启动 warn」是设计抽风,splitPattern 跟 hook 决策完全正交) - new: reasoningSplitPattern / errorSplitPattern — 按 messageKind 独立的句切配置,两个 kind 默认不切 - new: reasoningChunkBytes handler option(默认 2000,null 禁用)— reasoning 超阈值自动按 UTF-8 边界切,绝大多数 reasoning-heavy 部署不再需要 BlobStore - ToolRequestPush 切片:toolCalls 绑定到含最后一段 prefix 的 chunk,前 N-1 段降级为 ContentPush(无 toolCalls) - 两层 cascade:reasoningSplitPattern(语义切)→ reasoningChunkBytes(字节切),Layer 1 段间 1500ms / Layer 2 chunk 间 100ms - new event: reasoning_chunked,仅在 byte chunking 实际触发时 fire 一次 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 57 + packages/rei-standard-amsg/instant/README.md | 200 ++- .../rei-standard-amsg/instant/package.json | 4 +- .../rei-standard-amsg/instant/src/index.js | 114 +- .../instant/src/message-processor.js | 360 ++++- .../instant/src/validation.js | 42 +- .../instant/test/split-pattern-hook.test.mjs | 1267 +++++++++++++++++ .../rei-standard-amsg/shared/CHANGELOG.md | 16 + .../rei-standard-amsg/shared/package.json | 2 +- .../rei-standard-amsg/shared/src/index.js | 129 +- .../shared/test/builders.test.mjs | 119 +- 11 files changed, 2256 insertions(+), 54 deletions(-) create mode 100644 packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 93a9a84..23cc5f2 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.2 — splitPattern hook-mode 修复 + reasoning 两层切分 (pre-release) + +Coordinated with `@rei-standard/amsg-shared@0.1.0-next.2`. Install with `npm install @rei-standard/amsg-instant@next`. + +### Fixed + +- **`splitPattern` 在 hook 模式下重新生效**。0.7 引入的「`splitPattern is ignored when onLLMOutput is provided` 启动 warn + 不切分」是设计抽风:`splitPattern` 是「消息文本切气泡」的 UX 配置,跟 hook 决定「本轮发什么」完全正交。next.2 把它在 hook 模式下重新启用,hook 返回 `decision: 'finish'` / `'tool-request'` 后,framework 按 `messageKind` 对 pushPayload 的文本字段应用 `splitPattern`:`content.message` / `tool_request.message`(默认开,句号正则 `/([。!?!?]+)/`)。`ToolRequestPush` 切片时 `toolCalls` 仍是原子数组,绑定到含 LAST prefix 段的 chunk(emit 为 `tool_request`),前 N-1 段降级为 `content`(不带 `toolCalls`)— 保证 narration 全显示完再启动 tool 执行。 +- **删除 0.7 加的 `splitPattern is ignored when onLLMOutput is provided` 启动 warn**。 + +### New + +- **`reasoningSplitPattern` / `errorSplitPattern` payload 字段** — 按 `messageKind` 独立的句号切配置: + + | `messageKind` | 字段 | 默认 | + |----------------|---------------------------|---------------------| + | `content` | `splitPattern` | `/([。!?!?]+)/` (开) | + | `tool_request` | `splitPattern` | `/([。!?!?]+)/` (开) | + | `reasoning` | `reasoningSplitPattern` | **不切** | + | `error` | `errorSplitPattern` | **不切** | + | 自由 payload | — | 不切 | + + 四个 kind 共享的「禁用」语义:显式 `null` 或 `[]` 关闭切分。差别在 `undefined`(字段省略):`content` / `tool_request` 回落默认句号正则;`reasoning` / `error` 保持不切(这俩历史上就没切片 UX,默认 off 才符合预期)。 + +- **`reasoningChunkBytes` handler option(默认 2000,`null` 禁用)** — `ReasoningPush.reasoningContent` 的 UTF-8 字节上限。reasoning-heavy LLM(DeepSeek-R1 / GLM-4.5 / Qwen3-Thinking)经常输出 3-10 KB reasoning,超 Web Push ~2.6 KB 上限。next.2 内置 transparent 字节切分:超限时按 UTF-8 codepoint 边界切成 N 份,每片带 `chunkIndex` / `totalChunks`,SW 按这两个字段拼回完整字符串。**绝大多数 reasoning-heavy 部署不再需要 BlobStore。** `createInstantHandler` 构造期校验 `reasoningChunkBytes ∈ [500, maxInlineBytes - 600]`(600 B 余量给 push payload 元字段),不合法抛 `TypeError`。 + +- **两层 cascade(Layer 1 句切 → Layer 2 字节切)** — `reasoningSplitPattern` 先按句切成 M 段,每段单独量字节,超阈值的段再字节切成 N 块。最终 push 同时带两组索引: + - Layer 1:`messageIndex` 1..M / `totalMessages` M(M=1 时不写) + - Layer 2:`chunkIndex` 1..N / `totalChunks` N(N=1 时不写) + + SW 拼接:按 `sessionId` 分桶 → 按 `messageIndex` 分子桶 → 按 `chunkIndex` 排序拼字符串。 + +- **新事件 `reasoning_chunked`** — `{ sessionId, iteration?, totalChunks, totalBytes }`。只在 Layer 2 实际切分时 fire 一次(Layer 1 单独的句切不 fire),避免事件洪水。 + +- **`chunkReasoningByUtf8Bytes` re-export** — 从 `@rei-standard/amsg-shared` 直接 re-export 出来,hook 作者想自己切(`autoEmitReasoning: false` + 手动 dispatch)也能用。 + +### 行为兼容 + +- 不传任何新字段:`reasoning_content` 小于 2000 B 时 wire format 跟 next.1 byte-for-byte 一致。 +- 老 SW 拿到单 chunk 单 segment 的 ReasoningPush 完全照常消费(新字段都 optional,单值时不写)。 +- HOOK_THREW 诊断仍走 `sendWebPush` 单 shot(特殊路径,跟 byte chunking 解耦)。 +- LOOP_EXCEEDED 诊断走 `sendChunkedPush` 仍然遵循 `errorSplitPattern`(默认不切)。 + +### 投递时序 + +- Layer 1 段间间隔:`SLEEP_BETWEEN_MESSAGES_MS`(1500 ms,typing-bubble UX) +- Layer 2 同段 chunk 间间隔:`SLEEP_BETWEEN_REASONING_CHUNKS_MS`(100 ms,transport-only,不需要打字感) +- 一律串行,每个 chunk 等前一个 push 返回再发,避免 push gateway 速率限制 + SW 按 `chunkIndex` 重排 +- 内部统一通过 `sendPushWithMaybeBlob`,单 chunk 超限仍可走 BlobStore envelope(兜底未变) + +### Unchanged + +- hook API(4-decision 契约)/ agentic loop / `/continue` / `maxLoopIterations` / `autoEmitReasoning` 全部不变 +- BlobStore 路径、envelope schema、`maxInlineBytes` 等不变 +- 凭据(vapid / apiKey / pushSubscription)继续不暴露给 hook +- 不引入新错误码、不改 HTTP 状态码映射 +- `runLegacyInstant`(不传 `onLLMOutput` 的 0.6 兼容路径)也吃 Layer 2 字节切,跟 `runAgenticLoop` 行为一致 + ## 0.8.0-next.1 — avatarUrl 软清空 (pre-release) Cherry-pick stable `0.7.1` 的 `avatarUrl` 软清空策略到 next 预发布线。`/instant` 与 `/continue` 路径不合法的 `avatarUrl`(`data:` URI / 长度 > 2048 / 非字符串 / 不是合法 URL)会在 payload 上**置为 `null`** + `console.warn`,整次推送继续;`INVALID_PAYLOAD_FORMAT` 不再为 `avatarUrl` 触发,其它字段错误码不变。详见 `0.7.1` stable 条目;与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-client` 2.3.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index fb81ade..2465b7c 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -39,6 +39,8 @@ npm install @rei-standard/amsg-instant | `onLLMOutput` | function | ❌ | **0.7.0+**:每轮 LLM 输出后的决策钩子。配了它就进 agentic loop 模式;不配则走 v0.6 老路径(字节级兼容)。见 [Agentic Loop](#agentic-loop070) | | `blobStore` | object | ❌ | **0.7.0+**:可选 blob 后端。push payload UTF-8 字节超过 `maxInlineBytes`(默认 2600)时自动把 body 写进 store、改推 200 B envelope。见 [BlobStore](#blobstore070) | | `maxLoopIterations` | number | ❌ | **0.7.0+**:单次 worker 调用内 `decision:'continue'` 的硬上限,默认 10。仅防本进程内 hook 反复 continue 失控;跨请求的 `/continue` 洪水攻击由上游 auth/rate-limit 处理 | +| `autoEmitReasoning` | boolean | ❌ | **0.8.0+**:默认 `true`。`true` 时框架在调 hook 前自动 emit `ReasoningPush`(如果 LLM 响应带非空 `reasoning_content`)。`false` 把 reasoning emit 完全交给 hook 自己负责(hook 可读 `ctx.llmResponse.choices[0].message.reasoning_content` 并用 `buildReasoningPush` + 自己 dispatch)。legacy 路径忽略此项始终自动 emit。 | +| `reasoningChunkBytes` | number \| null | ❌ | **0.8.0-next.2+**:`ReasoningPush.reasoningContent` 的 UTF-8 字节上限。默认 `2000` — reasoning 超 2 KB 时框架按 codepoint 边界切成 N 份带 `chunkIndex` / `totalChunks` 投递,SW 拼接还原。设 `null` 禁用字节切(超限走 BlobStore 或抛 `PAYLOAD_TOO_LARGE`)。构造期校验范围 `[500, maxInlineBytes - 600]`,不合法抛 `TypeError`。详见 [Reasoning chunking](#reasoning-chunking080-next2)。 | ### 鉴权策略 @@ -129,8 +131,10 @@ Content-Type: application/json temperature?: number; // 0.5.0+:透传给 LLM;completePrompt 路径未传默认 0.8 messageSubtype?: string; // SW 端分类标签,取值由业务决定 - // === 分句正则(0.6.0+),可选 === - splitPattern?: string | string[]; // 自定义把 LLM 输出切成多条推送的正则;不传走默认 /([。!?!?]+)/ + // === 分句正则(按 messageKind 独立配置),可选 === + splitPattern?: string | string[] | null; // content / tool_request:默认 /([。!?!?]+)/,null/[] 关闭 + reasoningSplitPattern?: string | string[] | null; // 0.8.0-next.2+,reasoning:默认不切;传了就按这个切 + errorSplitPattern?: string | string[] | null; // 0.8.0-next.2+,error:默认不切;传了就按这个切 pushSubscription: { // Web Push 标准订阅 endpoint: string; @@ -180,9 +184,15 @@ curl -X POST https://instant.example.com/instant \ }' ``` -#### `splitPattern`:自定义分句正则(0.6.0+) +#### `splitPattern` 系列:按 `messageKind` 独立的分句正则(0.6.0+ / 0.8.0-next.2+) -LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送(每条之间间隔 1.5s,看起来像真人一句句打字)。`splitPattern` 让调用方覆盖这个正则: +LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送(每条之间间隔 1.5s,看起来像真人一句句打字)。三个字段各管一类 push 的切分: + +| 字段 | 控制的 `messageKind` | 默认(字段省略时) | +|---------------------------|--------------------------------|----------------------------| +| `splitPattern` | `content` / `tool_request` | `/([。!?!?]+)/`(开) | +| `reasoningSplitPattern` | `reasoning` | **不切** | +| `errorSplitPattern` | `error` | **不切** | ```jsonc // 单正则:按换行切 @@ -190,15 +200,25 @@ LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送(每 // 数组:级联——先按段落切,每段再按句号切 { "splitPattern": ["(\\n\\n+)", "([。!?!?]+)"] } + +// reasoning 长文本想切气泡:默认不切,得显式传 +{ "reasoningSplitPattern": "([。!?!?]+)" } + +// 关闭 content 的默认切分(整段一条 push) +{ "splitPattern": null } ``` -**约定**: +**`ToolRequestPush` 切片特殊处理**:`toolCalls` 是原子数组不切。`message` 切成 N 段时前 N-1 段降级为 `messageKind: 'content'`(不带 `toolCalls`),最后一段保留 `tool_request` + 完整 `toolCalls`,保证 narration 全显示完再启动 tool 执行。 + +**通用约定**: - 传**正则 source**,不要带两边的 `/.../` 也不要带尾部 flag(`/foo/i` 会被当字面量斜杠 + 字面量 `i` 匹配)。需要大小写不敏感请用 `[Aa]` 这种字符类替代。 - 想保留分隔符(默认就是把句号回贴到前一段),把分隔符包进 `(...)` 捕获组。库不会自动包——传 `"\\n+"` 而不是 `"(\\n+)"` 会得到首尾相连、分隔符丢失的奇怪结果。 - 数组语义是**级联**(split → split → split),不是"任一匹配就切"。需要后者请用 `|` 自己合一条正则。 - 上限:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PAYLOAD_FORMAT`。 -- 不传 / `null` / `[]` → 走默认正则,行为字节级与 0.5.x 一致。 +- **`undefined` vs `null` / `[]` 语义不同**: + - `splitPattern`:`undefined` = 用默认正则;`null` / `[]` = 关闭切分。 + - `reasoningSplitPattern` / `errorSplitPattern`:`undefined` = 不切(保守默认);`null` / `[]` 也是不切(显式关闭,效果一样)。这俩 kind 默认 off,是因为它们历史上就没切片 UX,引入 default-on 会改老 caller 行为。 #### `apiUrl` 规范化(0.4.0+) @@ -424,6 +444,165 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): --- +## Reasoning chunking(0.8.0-next.2+) + +reasoning-heavy LLM(DeepSeek-R1 / GLM-4.5 / Qwen3-Thinking 等)经常输出 3-10 KB `reasoning_content`,远超 Web Push 单 payload ~2.6 KB 安全线。next.2 内置 transparent 字节切分:framework 在产出 `ReasoningPush` 时自动按 UTF-8 codepoint 边界切成 N 份带 `chunkIndex` / `totalChunks` 投递,SW 拼回完整字符串。**绝大多数 reasoning-heavy 部署不再需要 BlobStore。** + +### 两层 cascade + +``` +reasoningContent + │ + ▼ +Layer 1 — 语义切(reasoningSplitPattern,默认 OFF) + • 按 regex 切成 M 段,每段带 messageIndex 1..M / totalMessages M + │ + ▼ 对每个 Layer-1 段独立量字节 +Layer 2 — 字节切(reasoningChunkBytes,默认 ON,2000 B) + • 段字节 ≤ 阈值:单 push(不写 chunkIndex / totalChunks) + • 段字节 > 阈值:codepoint 边界切成 N 份,每片带 chunkIndex 1..N / totalChunks N + │ + ▼ +serial dispatch via sendPushWithMaybeBlob + • 同段 Layer-2 chunk 间间隔 100 ms(transport-only) + • Layer-1 段间间隔 1500 ms(typing-bubble UX) +``` + +### 默认配置 = 透明 + +零配置就 work: + +```js +createInstantHandler({ + vapid: { ... }, + onLLMOutput: hook, + // reasoningChunkBytes 默认 2000 — 不需要配 +}); +``` + +- 短 reasoning(< 2000 B):单 push,wire 跟 next.1 byte-for-byte 一致。 +- 长 reasoning(> 2000 B):自动切分,老 SW 拿到不带 `chunkIndex` 的单 push 走老路径;新 SW 看到 `chunkIndex` / `totalChunks` 走累积拼接。 + +### 显式禁用 byte chunking + +```js +createInstantHandler({ + vapid: { ... }, + onLLMOutput: hook, + reasoningChunkBytes: null, // 关闭 Layer 2 + blobStore: { adapter: ... }, // 大 reasoning 走 envelope,没配 blobStore 会抛 PAYLOAD_TOO_LARGE +}); +``` + +`reasoningSplitPattern` 和 `reasoningChunkBytes` 是**两个独立开关**: +- `reasoningSplitPattern: null` 只关 Layer 1(句切),不影响 Layer 2 字节切。 +- `reasoningChunkBytes: null` 只关 Layer 2(字节切),不影响 Layer 1 句切。 + +### Wire format + +#### 单 chunk(≤ 阈值,无 Layer 1) — 跟 next.1 完全一致 + +```json +{ + "messageKind": "reasoning", + "messageType": "instant", + "source": "instant", + "messageId": "msg__iter_0_reasoning", + "sessionId": "sess_abc", + "timestamp": "2026-05-20T12:00:00Z", + "reasoningContent": "short reasoning…" +} +``` + +#### Pure Layer 2(无句切,大 reasoning) + +```json +// Chunk 1 of 3 +{ + "messageKind": "reasoning", + "messageId": "msg__iter_0_reasoning_chunk_1", + "sessionId": "sess_abc", + "chunkIndex": 1, + "totalChunks": 3, + "reasoningContent": "first 2000 bytes…" +} +``` + +#### Cascade(Layer 1 + Layer 2) + +```json +// Layer-1 段 2/3,Layer-2 chunk 1/3 +{ + "messageKind": "reasoning", + "messageId": "msg__iter_0_reasoning_chunk_1", + "sessionId": "sess_abc", + "messageIndex": 2, + "totalMessages": 3, + "chunkIndex": 1, + "totalChunks": 3, + "reasoningContent": "first 2000 bytes of sentence 2…" +} +``` + +### SW 端拼接合约 + +```js +// 伪代码 — 在 SW 的 'push' 事件 handler 里 +const buffers = new Map(); // sessionId → { [messageIndex]: { chunks: Map, total: number } } + +function onReasoningPush(p) { + // Single-shot — neither axis present. 直接消费。 + if (p.chunkIndex === undefined && p.messageIndex === undefined) { + return deliverComplete(p.sessionId, p.reasoningContent); + } + + // 按 (sessionId, messageIndex) 分桶 — messageIndex 不存在视作 0。 + const segIdx = p.messageIndex ?? 0; + const segTotal = p.totalMessages ?? 1; + const chunkIdx = p.chunkIndex ?? 1; + const chunkTotal = p.totalChunks ?? 1; + + const bySession = buffers.get(p.sessionId) ?? new Map(); + buffers.set(p.sessionId, bySession); + const seg = bySession.get(segIdx) ?? { chunks: new Map(), total: chunkTotal }; + seg.chunks.set(chunkIdx, p.reasoningContent); + bySession.set(segIdx, seg); + + // 检查所有 segIdx 1..segTotal 都到齐 + 每段 chunks 1..total 都到齐 → 拼接消费。 + if (bySession.size === segTotal && + [...bySession.values()].every(s => s.chunks.size === s.total)) { + const full = [...bySession.entries()] + .sort(([a],[b]) => a - b) + .map(([_, s]) => [...s.chunks.entries()].sort(([a],[b]) => a - b).map(([_, t]) => t).join('')) + .join(''); + deliverComplete(p.sessionId, full); + buffers.delete(p.sessionId); + } +} +``` + +**关键不变量**: +- `chunkIndex` / `totalChunks` 仅在 byte 切实际发生(N > 1)时出现,单 chunk 一律省略。 +- `messageIndex` / `totalMessages` 仅在 `reasoningSplitPattern` 实际切了(M > 1)时出现。 +- Web Push 到达顺序**不保证**,SW 必须按 `chunkIndex` 排序。 +- 跨 sessionId 不要混。每个 LLM round 一个 sessionId。 + +### 事件 + +framework 在 Layer 2 实际触发时 fire 一次 `reasoning_chunked`: + +```js +onEvent: (e) => { + if (e.type === 'reasoning_chunked') { + console.log(`session=${e.sessionId} bytes=${e.totalBytes} chunks=${e.totalChunks} iter=${e.iteration}`); + } +} +``` + +Layer 1 单独的句切不 fire 此事件(用户自己配的,可观测性走业务日志)。 + +--- + ## BlobStore(0.7.0+) ### 为什么需要它 @@ -449,16 +628,17 @@ agentic loop 模式下 payload 大小分布(经验值): | tool-request push(带本轮 LLM 原文) | 0.8–1.8 KB | 2.5 KB | 3.5 KB | | tool-request + reasoning | 1.5–3 KB | 4 KB(临界) | 5–6 KB(超) | -→ **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。`BlobStore` 是**兜底**:超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, type? }`,SW 端 `GET ${url}` 拿真 body。 +→ **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。0.8.0-next.2 引入 [reasoning byte chunking](#reasoning-chunking080-next2) 后,reasoning 超限的场景默认自动切分不再依赖 BlobStore;`BlobStore` 主要是 ContentPush / ToolRequestPush 超限的兜底(以及 reasoning byte chunking 被显式关闭时的 fallback):超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, type? }`,SW 端 `GET ${url}` 拿真 body。 ### 何时启用 / 何时跳过 | 场景 | 推荐 | |---|---| | 不配 `onLLMOutput`(v0.6 legacy 路径) | **不需要配** —— 分句拆出来每段都 < 1 KB | -| agentic loop,不开 reasoning、不带 replay history | 建议配(保守安全线下 p90 接近撞线) | -| agentic loop,开 reasoning 显示 | **强烈推荐** —— 必撞 | -| 任何 tool-request 流程 | 推荐配 | +| agentic loop,只用 reasoning,不带长 ContentPush | **不需要配** —— next.2 起 reasoning 自动 byte chunking,2 KB / chunk | +| agentic loop,ContentPush 偶尔很长(代码块 / 长答案) | 推荐配 | +| 显式关闭 `reasoningChunkBytes: null` | **强烈推荐** —— 大 reasoning 兜底走 envelope | +| 任何 tool-request 流程 | 推荐配(toolCalls + narration 偶尔会撞线) | | 一次推完整 history | **必须配** —— 必超 | **不配 + 超限的行为**:抛 `PayloadTooLargeError` + emit `payload_too_large`,不静默截断。调用方据此决定要不要上 BlobStore。 diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index d043ef0..1f722ba 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.1", + "version": "0.8.0-next.2", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", @@ -84,7 +84,7 @@ "node": ">=18" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0" + "@rei-standard/amsg-shared": "0.1.0-next.2" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 5694d7c..2f0011d 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -48,6 +48,34 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- /** * @typedef {Object} InstantHandlerOptions * @property {VapidConfig} vapid - VAPID keys for Web Push. + * + * Note on per-kind split-pattern fields (request-payload, not handler options): + * + * Each `messageKind` reads its own pattern field with its own default: + * + * | messageKind | field on payload | default | + * |----------------|---------------------------|-----------------------------| + * | `content` | `splitPattern` | `/([。!?!?]+)/` (split-on) | + * | `tool_request` | `splitPattern` | `/([。!?!?]+)/` (split-on) | + * | `reasoning` | `reasoningSplitPattern` | **not split** | + * | `error` | `errorSplitPattern` | **not split** | + * | free-form | — | not split | + * + * Disable semantics: an explicit `null` or `[]` disables splitting + * for that kind. The asymmetry sits in `undefined` (field absent): + * `content` / `tool_request` fall back to the default sentence + * regex; `reasoning` / `error` stay unsplit. This makes the + * default-on / default-off bucket explicit in the wire format. + * + * ToolRequestPush splitting demotes prefix chunks to `content` + * (without `toolCalls`) and binds `toolCalls` to the LAST prefix + * segment (kept as `tool_request`), so narration finishes before + * the client starts executing tools. + * + * Auto-emitted ReasoningPush (from `choices[0].message.reasoning_content`) + * and framework-built ErrorPush diagnostics (`LOOP_EXCEEDED`) both + * read the same kind-specific fields. `HOOK_THREW` is a + * special-case single-shot diagnostic and bypasses the splitter. * @property {string} [clientToken] - Optional shared secret. When set, requests must send a matching `X-Client-Token` header. Weak by design: the token is visible in any frontend bundle that uses it. Use `tokenSigningKey` for real auth. * @property {string} [tokenSigningKey] - Optional HMAC key. When set, `Authorization: Bearer ` is verified. * @property {CorsConfig} [cors] - CORS configuration. Defaults to `{ allowOrigin: '*' }`. Every response (including the 204 preflight short-circuit) carries the matching `Access-Control-Allow-*` headers. @@ -83,6 +111,25 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * read `ctx.llmResponse.choices[0].message.reasoning_content` * and produce its own `buildReasoningPush(...)` envelope. * Legacy (non-hook) path always auto-emits regardless. + * @property {number | null} [reasoningChunkBytes=2000] + * - **next.2 transport knob.** Cap on the UTF-8 byte size + * of a single `ReasoningPush.reasoningContent`. When the + * auto-emitted (or hook-returned) reasoning exceeds this + * threshold, the framework slices it at UTF-8 codepoint + * boundaries via `chunkReasoningByUtf8Bytes` and ships N + * ReasoningPushes with `chunkIndex` / `totalChunks` set; + * the SW reassembles by sorting chunks within a + * `(sessionId, messageIndex)` bucket. Default 2000 keeps + * each full push payload (incl. envelope overhead) safely + * under the 2.6 KB Web Push limit without BlobStore. + * Set to `null` to disable byte chunking entirely — + * oversized reasoning then falls back to BlobStore (if + * configured) or throws `PayloadTooLargeError`. + * Layered with `reasoningSplitPattern` (sentence regex, + * request-payload field): sentence-split runs first, then + * oversized sentences cascade-chunk by byte. Throws + * `TypeError` at handler construction when not in + * `[500, maxInlineBytes - 600]` (or `null`). */ /** @@ -122,18 +169,10 @@ export function createInstantHandler(options) { // most hook callers. The legacy path ignores this setting and // always auto-emits. const autoEmitReasoning = options.autoEmitReasoning !== false; - - // One-shot startup warning: a caller who sets both `onLLMOutput` - // and `splitPattern` almost certainly hasn't realised the hook - // path doesn't run the sentence splitter. We don't fail (the combo - // is benign), just nudge them in the console so the dead config - // doesn't go unnoticed in production logs. - if (onLLMOutput && options.splitPattern !== undefined) { - const warn = globalThis.console && globalThis.console.warn; - if (typeof warn === 'function') { - warn('[amsg-instant] splitPattern is ignored when onLLMOutput is provided. Move splitting logic into your hook if needed.'); - } - } + // Eager validation: `reasoningChunkBytes` throws at handler + // construction (not on the first request) so misconfiguration + // surfaces in startup logs / unit tests, not in production traffic. + const reasoningChunkBytes = resolveReasoningChunkBytes(options, blobStore); // Validate VAPID shape eagerly so misconfiguration surfaces on the very // first request rather than the first Web Push attempt. @@ -269,6 +308,7 @@ export function createInstantHandler(options) { blobStore, maxLoopIterations, autoEmitReasoning, + reasoningChunkBytes, requestUrl: request.url, isResume: isContinue, }); @@ -292,6 +332,55 @@ export function createInstantHandler(options) { }; } +// Defaults pinned at module scope so the validator and the resolver +// agree without a second source of truth. +const DEFAULT_REASONING_CHUNK_BYTES = 2000; +const DEFAULT_MAX_INLINE_BYTES_FOR_OVERHEAD_CHECK = 2600; +const REASONING_CHUNK_BYTES_MIN = 500; +const REASONING_CHUNK_OVERHEAD_MARGIN = 600; + +/** + * Resolve and validate `options.reasoningChunkBytes`. Returns the + * resolved sentinel value to pass into `processInstantMessage`'s ctx: + * - positive integer when chunking is enabled (caller-provided or default 2000) + * - `null` when chunking is explicitly disabled + * + * Throws `TypeError` at handler construction (NOT at request time) so + * deploys with bad config fail fast in their startup logs / CI rather + * than ship a worker that crashes on the first reasoning-heavy LLM + * response. + * + * The acceptable range is `[REASONING_CHUNK_BYTES_MIN, + * maxInlineBytes - REASONING_CHUNK_OVERHEAD_MARGIN]`. The 600 B + * overhead margin reserves space for a chunk's push-payload metadata + * (messageKind / sessionId / messageId / chunkIndex / totalChunks / + * timestamp / contactName / avatarUrl / messageSubtype) so a chunk + * sized exactly at `reasoningChunkBytes` still fits inline. + * + * @param {Object} options + * @param {import('./blob-store/interface.js').BlobStoreConfig | null} blobStore + * @returns {number | null} + */ +function resolveReasoningChunkBytes(options, blobStore) { + const raw = options.reasoningChunkBytes; + if (raw === undefined) return DEFAULT_REASONING_CHUNK_BYTES; + if (raw === null) return null; + const maxInline = (blobStore && Number.isInteger(blobStore.maxInlineBytes) && blobStore.maxInlineBytes > 0) + ? blobStore.maxInlineBytes + : DEFAULT_MAX_INLINE_BYTES_FOR_OVERHEAD_CHECK; + const upperBound = maxInline - REASONING_CHUNK_OVERHEAD_MARGIN; + if ( + !Number.isInteger(raw) || + raw < REASONING_CHUNK_BYTES_MIN || + raw > upperBound + ) { + throw new TypeError( + `[amsg-instant] reasoningChunkBytes must be a positive integer in [${REASONING_CHUNK_BYTES_MIN}, ${upperBound}] (= maxInlineBytes ${maxInline} − ${REASONING_CHUNK_OVERHEAD_MARGIN} overhead margin), or null to disable. Got: ${raw}` + ); + } + return raw; +} + /** * Map an Error to its HTTP status code. `HookError` is in-process * caller-supplied code that misbehaved — 500. `LlmCallError` / @@ -547,4 +636,5 @@ export { isReasoningPush, isToolRequestPush, isErrorPush, + chunkReasoningByUtf8Bytes, } from '@rei-standard/amsg-shared'; diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index e887d98..404a721 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -19,6 +19,7 @@ import { buildContentPush, buildReasoningPush, buildErrorPush, + chunkReasoningByUtf8Bytes, } from '@rei-standard/amsg-shared'; import { sendWebPush } from './webpush.js'; @@ -27,6 +28,16 @@ import { HookError, LlmCallError, PayloadTooLargeError } from './errors.js'; import { buildSessionContext, extractAssistantMessage } from './session-context.js'; const SLEEP_BETWEEN_MESSAGES_MS = 1500; +// Sub-chunk spacing within a single Layer-1 segment. Byte-chunking is +// a transport-level workaround (Web Push payload limit), NOT a +// typing-bubble UX axis, so the inter-chunk gap is much smaller than +// the inter-sentence gap. 100 ms is enough to avoid pummelling the +// push gateway in a tight loop while keeping perceived latency low. +const SLEEP_BETWEEN_REASONING_CHUNKS_MS = 100; +// Mirrors `DEFAULT_REASONING_CHUNK_BYTES` in `index.js` — kept in sync +// so `processInstantMessage` callers that bypass `createInstantHandler` +// (tests, direct programmatic use) still get the same default. +const DEFAULT_REASONING_CHUNK_BYTES = 2000; const DEFAULT_MAX_LOOP_ITERATIONS = 10; const DEFAULT_MAX_INLINE_BYTES = 2600; @@ -92,6 +103,307 @@ export function splitMessageIntoSentences(messageContent, splitPattern = null) { return chunks.length > 0 ? chunks : [messageContent]; } +/** + * Pick the right split-pattern field + disable semantics for a given + * `messageKind`. The three kinds split with different defaults: + * + * | messageKind | field on payload | default when field is absent | + * |----------------|---------------------------|------------------------------| + * | `content` | `splitPattern` | sentence regex (split on) | + * | `tool_request` | `splitPattern` | sentence regex (split on) | + * | `reasoning` | `reasoningSplitPattern` | **no split** | + * | `error` | `errorSplitPattern` | **no split** | + * + * Disable semantics in all four cases: an explicit `null` or `[]` + * disables splitting. The asymmetry is in `undefined` (i.e. caller + * omitted the field): for `content` / `tool_request` that means "use + * default sentence regex" (preserves 0.6 UX); for `reasoning` / + * `error` it means "do not split" (the kinds that didn't have a UX + * for splitting historically). + * + * @param {Record} payload + * @param {unknown} kind + * @returns {{ textField: 'message' | 'reasoningContent', pattern: unknown, disabled: boolean } | null} + * `null` when the kind is not splittable (`'error'` with no opt-in + * pattern, unknown / free-form kinds). + */ +function pickSplitConfig(payload, kind) { + if (kind === 'content' || kind === 'tool_request') { + const pattern = payload.splitPattern; + const disabled = pattern === null + || (Array.isArray(pattern) && pattern.length === 0); + return { textField: 'message', pattern, disabled }; + } + if (kind === 'reasoning') { + const pattern = payload.reasoningSplitPattern; + // Default-off: undefined / null / [] all mean "do not split". + const disabled = pattern === undefined + || pattern === null + || (Array.isArray(pattern) && pattern.length === 0); + return { textField: 'reasoningContent', pattern, disabled }; + } + if (kind === 'error') { + const pattern = payload.errorSplitPattern; + // Default-off, same as reasoning. + const disabled = pattern === undefined + || pattern === null + || (Array.isArray(pattern) && pattern.length === 0); + return { textField: 'message', pattern, disabled }; + } + return null; +} + +/** + * Per-kind splitter. Given a `pushPayload` and the request `payload` + * (which carries the kind-specific split-pattern fields), apply the + * right pattern to the kind's text field and return an array of + * one-per-push payloads ready for sequential delivery. + * + * Routing per `messageKind`: + * - `'content'` → reads `payload.splitPattern`, splits `message` + * - `'reasoning'` → reads `payload.reasoningSplitPattern`, splits + * `reasoningContent` (default-off) + * - `'tool_request'` → reads `payload.splitPattern`, splits + * `message`; `toolCalls` binds to the LAST + * prefix chunk (emitted as `tool_request`). + * Chunks 0..N-2 are demoted to `messageKind: + * 'content'` (without `toolCalls`) so the + * narration finishes BEFORE the client starts + * executing tools. + * - `'error'` → reads `payload.errorSplitPattern`, splits + * `message` (default-off) + * - anything else → passthrough; the framework can't guess which + * field of a free-form hook payload to split. + * + * The original payload's `toolCalls`, `metadata`, and all push + * metadata fields (`messageType` / `source` / `sessionId` / `timestamp` + * / `messageKind` / `messageSubtype` / `taskId`) are preserved + * verbatim per chunk. Only `messageId` is regenerated per chunk + * (independent IDs, shared sessionId) and `messageIndex` / + * `totalMessages` are populated 1-based. + * + * @param {unknown} pushPayload + * @param {Record} payload - The validated request payload. + * @returns {Array} - Length ≥ 1. Single-element when not + * splittable or when the split produces + * one chunk (so callers always loop). + */ +function splitHookPushPayload(pushPayload, payload) { + if (!pushPayload || typeof pushPayload !== 'object' || Array.isArray(pushPayload)) { + return [pushPayload]; + } + const pushObj = /** @type {Record} */ (pushPayload); + const kind = pushObj.messageKind; + + const cfg = pickSplitConfig(payload || {}, kind); + if (!cfg || cfg.disabled) return [pushPayload]; + + const text = pushObj[cfg.textField]; + if (typeof text !== 'string' || text.length === 0) return [pushPayload]; + + const segments = splitMessageIntoSentences(text, cfg.pattern); + if (segments.length <= 1) return [pushPayload]; + + const total = segments.length; + return segments.map((segment, i) => { + const isLast = i === total - 1; + const chunkMessageId = `msg_${randomUUID()}_chunk_${i}`; + + if (kind === 'tool_request' && !isLast) { + // Demote prefix chunks to ContentPush — drop `toolCalls` so the + // client UI doesn't try to execute the tool N times. The last + // chunk (below) keeps the original kind + toolCalls intact. + const { toolCalls: _drop, ...rest } = pushObj; + return { + ...rest, + messageKind: 'content', + messageId: chunkMessageId, + message: segment, + messageIndex: i + 1, + totalMessages: total, + }; + } + + return { + ...pushObj, + messageId: chunkMessageId, + [cfg.textField]: segment, + messageIndex: i + 1, + totalMessages: total, + }; + }); +} + +/** + * Split `pushPayload` per kind and ship the chunks sequentially with + * `SLEEP_BETWEEN_MESSAGES_MS` spacing. Each chunk goes through + * `sendPushWithMaybeBlob` so the blob detour still applies per-chunk. + * + * Returns the chunk count actually emitted (callers use this for + * `messagesSent` event payloads). Throws on the first chunk that + * fails delivery — caller decides whether that aborts the whole turn + * or is best-effort (e.g. auto-emitted reasoning is best-effort). + * + * @param {unknown} pushPayload + * @param {Record} payload + * @param {Object} ctx + * @param {string} sessionId + * @param {(ms: number) => Promise} sleep + * @returns {Promise} + */ +async function sendChunkedPush(pushPayload, payload, ctx, sessionId, sleep) { + const chunks = splitHookPushPayload(pushPayload, payload); + for (let i = 0; i < chunks.length; i++) { + await sendPushWithMaybeBlob(chunks[i], payload, ctx, sessionId); + if (i < chunks.length - 1) { + await sleep(SLEEP_BETWEEN_MESSAGES_MS); + } + } + return chunks.length; +} + +// ─── Reasoning two-layer cascade ──────────────────────────────────────── + +/** + * Expand a single `ReasoningPush` into the flat leaf array of pushes + * the framework will actually deliver, applying the two-layer cascade: + * + * Layer 1 — semantic split via `payload.reasoningSplitPattern` + * (delegates to `splitHookPushPayload`). Default-off; + * when set, produces M segments carrying + * `messageIndex` / `totalMessages`. + * + * Layer 2 — UTF-8 byte chunking via `reasoningChunkBytes` ctx + * knob. Default-on (threshold 2000 B); when a Layer-1 + * segment exceeds the threshold, the segment is sliced at + * codepoint boundaries (via `chunkReasoningByUtf8Bytes`) + * into N sub-pushes carrying `chunkIndex` / `totalChunks`. + * `null` disables Layer 2 entirely — oversized segments + * then fall through to `sendPushWithMaybeBlob` and either + * hit BlobStore (if configured) or throw + * `PayloadTooLargeError`. + * + * Layer 1 fields (messageIndex / totalMessages) come straight from + * `splitHookPushPayload`. Layer 2 fields (chunkIndex / totalChunks) + * are added per-leaf when N > 1; otherwise the leaf wire-matches the + * pre-byte-chunking shape byte-for-byte. + * + * `messageId` is regenerated per leaf so each push has a unique id: + * `msg__iter__reasoning_chunk_` + * + * @param {Object} reasoningPush + * @param {Object} payload + * @param {number | null | undefined} reasoningChunkBytes + * @param {number | undefined} iteration - 0 for legacy path, the agentic-loop iteration otherwise. + * @returns {Array} + */ +function expandReasoningPushChunks(reasoningPush, payload, reasoningChunkBytes, iteration) { + // Layer 1: defer to the shared splitter. Returns 1 element when + // `reasoningSplitPattern` is unset/disabled; ≥2 when sentence split + // produces multiple segments. + const layer1 = splitHookPushPayload(reasoningPush, payload); + + // Resolve the byte threshold: + // - `null` → Layer 2 explicitly disabled + // - `undefined`→ ctx didn't carry the resolved option (callers that + // invoke `processInstantMessage` directly, e.g. tests). + // Fall back to the same default as `createInstantHandler`. + // - positive integer → use as threshold + if (reasoningChunkBytes === null) return layer1; + const threshold = (Number.isInteger(reasoningChunkBytes) && reasoningChunkBytes >= 4) + ? reasoningChunkBytes + : DEFAULT_REASONING_CHUNK_BYTES; + + /** @type {Array} */ + const out = []; + for (const segment of layer1) { + const text = segment && typeof segment === 'object' + ? /** @type {{reasoningContent?: unknown}} */ (segment).reasoningContent + : undefined; + if (typeof text !== 'string' || text.length === 0) { + out.push(segment); + continue; + } + const byteLen = PUSH_PAYLOAD_BYTE_ENCODER.encode(text).byteLength; + if (byteLen <= threshold) { + out.push(segment); + continue; + } + const pieces = chunkReasoningByUtf8Bytes(text, threshold); + const totalChunks = pieces.length; + const iterTag = Number.isInteger(iteration) ? iteration : 0; + for (let i = 0; i < totalChunks; i++) { + out.push({ + ...segment, + messageId: `msg_${randomUUID()}_iter_${iterTag}_reasoning_chunk_${i + 1}`, + reasoningContent: pieces[i], + chunkIndex: i + 1, + totalChunks, + }); + } + } + return out; +} + +/** + * Ship a `ReasoningPush` through the two-layer cascade. Serial + * delivery with `SLEEP_BETWEEN_REASONING_CHUNKS_MS` (100 ms) between + * Layer-2 chunks of the same Layer-1 segment, and + * `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) between Layer-1 segments — + * the larger gap preserves typing-bubble UX between sentences while + * the smaller gap keeps byte-chunking latency low. + * + * Fires a single `reasoning_chunked` event when Layer 2 actually + * produces > 1 chunk (independent of Layer 1 count) so operators see + * the byte-chunking trigger without per-chunk event noise. + * + * @param {Object} reasoningPush + * @param {Object} payload + * @param {Object} ctx + * @param {string} sessionId + * @param {(ms: number) => Promise} sleep + * @param {number | undefined} iteration + * @returns {Promise} Total leaves shipped. + */ +async function emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration) { + const leaves = expandReasoningPushChunks(reasoningPush, payload, ctx.reasoningChunkBytes, iteration); + + // Detect "byte chunking actually fired" — i.e. at least one leaf + // carries chunkIndex/totalChunks. We don't fire on Layer-1-only + // splits (those are user-configured semantic splits, not transport + // overflow events). + const byteChunked = leaves.some( + (l) => l && typeof l === 'object' && /** @type {{totalChunks?: unknown}} */ (l).totalChunks !== undefined + ); + if (byteChunked) { + const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; + const totalBytes = typeof reasoningPush.reasoningContent === 'string' + ? PUSH_PAYLOAD_BYTE_ENCODER.encode(reasoningPush.reasoningContent).byteLength + : 0; + const evt = { type: 'reasoning_chunked', sessionId, totalChunks: leaves.length, totalBytes }; + if (Number.isInteger(iteration)) evt.iteration = iteration; + onEvent(evt); + } + + for (let i = 0; i < leaves.length; i++) { + await sendPushWithMaybeBlob(leaves[i], payload, ctx, sessionId); + if (i < leaves.length - 1) { + // Same Layer-1 segment iff messageIndex matches (or neither has + // one — Layer 1 was disabled, all leaves are byte chunks of a + // single segment). + const cur = leaves[i]; + const next = leaves[i + 1]; + const curIdx = cur && typeof cur === 'object' + ? /** @type {{messageIndex?: unknown}} */ (cur).messageIndex : undefined; + const nextIdx = next && typeof next === 'object' + ? /** @type {{messageIndex?: unknown}} */ (next).messageIndex : undefined; + const sameSegment = curIdx === nextIdx; + await sleep(sameSegment ? SLEEP_BETWEEN_REASONING_CHUNKS_MS : SLEEP_BETWEEN_MESSAGES_MS); + } + } + return leaves.length; +} + /** * Normalize the AI API URL for OpenAI-compatible chat endpoints. * @@ -365,14 +677,17 @@ async function runLegacyInstant(payload, ctx) { // Best-effort: a failed reasoning push must NOT eclipse the // user-facing content burst. Mirrors the hook path's // `reasoning_push_failed` event (runAgenticLoop). + // + // Two-layer cascade via `emitReasoning`: + // Layer 1 — `payload.reasoningSplitPattern` (default off, sentence split) + // Layer 2 — `ctx.reasoningChunkBytes` (default 2000, byte chunking) + // Single reasoning < threshold + no sentence pattern → wire matches pre-next.2 exactly. let reasoningShipped = false; try { - await sendWebPush({ - subscription: pushSubscription, - payload: JSON.stringify(reasoningPush), - vapid: ctx.vapid, - fetch: fetchImpl, - }); + // Legacy path has no "iteration" — pass undefined so messageId + // template falls back to `iter_0` and the `reasoning_chunked` + // event omits the field. + await emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, undefined); reasoningShipped = true; onEvent({ type: 'reasoning_pushed', sessionId }); } catch (err) { @@ -453,6 +768,7 @@ async function runLegacyInstant(payload, ctx) { */ async function runAgenticLoop(payload, ctx) { const fetchImpl = ctx.fetch || globalThis.fetch; + const sleep = ctx.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; const maxLoopIterations = Number.isInteger(ctx.maxLoopIterations) && ctx.maxLoopIterations > 0 ? ctx.maxLoopIterations @@ -511,7 +827,12 @@ async function runAgenticLoop(payload, ctx) { metadata: payload.metadata || {}, }); try { - await sendPushWithMaybeBlob(reasoningPush, payload, ctx, sessionId); + // Two-layer cascade — Layer 1 (`reasoningSplitPattern`, + // default off) then Layer 2 (`reasoningChunkBytes`, default + // 2000 B). Default config: single short reasoning ships as + // one push (wire-identical to pre-next.2); long reasoning + // auto-chunks with `chunkIndex`/`totalChunks`. + await emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration); onEvent({ type: 'reasoning_pushed', sessionId, iteration }); } catch (err) { // Don't fail the whole turn for a reasoning instrumentation @@ -576,12 +897,27 @@ async function runAgenticLoop(payload, ctx) { return { status: 'skipped', sessionId, iteration }; } - // 'finish' or 'tool-request' — both deliver a push. - await sendPushWithMaybeBlob(decision.pushPayload, payload, ctx, sessionId); + // 'finish' or 'tool-request' — both deliver a push, with optional + // sentence-split per `messageKind`: + // `content` / `tool_request` → `payload.splitPattern` (default on) + // `reasoning` → `payload.reasoningSplitPattern` (default off) + // `error` → `payload.errorSplitPattern` (default off) + // free-form pushPayload → never split + // Reasoning additionally goes through Layer 2 byte chunking + // (`ctx.reasoningChunkBytes`, default 2000 B) so a hook returning + // a single large ReasoningPush still ships under the Web Push + // payload limit without forcing the hook author to slice. + const isReasoning = decision.pushPayload + && typeof decision.pushPayload === 'object' + && /** @type {{messageKind?: unknown}} */ (decision.pushPayload).messageKind === 'reasoning'; + const messagesSent = isReasoning + ? await emitReasoning(decision.pushPayload, payload, ctx, sessionId, sleep, iteration) + : await sendChunkedPush(decision.pushPayload, payload, ctx, sessionId, sleep); onEvent({ type: decision.decision === 'finish' ? 'final_pushed' : 'tool_request_pushed', sessionId, iteration, + messagesSent, }); return { status: decision.decision === 'finish' ? 'finished' : 'tool_requested', sessionId, iteration }; } @@ -599,7 +935,11 @@ async function runAgenticLoop(payload, ctx) { timestamp: new Date().toISOString(), }); try { - await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); + // Honour `payload.errorSplitPattern` so a caller that configured + // diagnostic chunking gets it consistently across hook-returned + // ErrorPushes and framework-emitted ones. Default-off → single + // push, same as pre-next.2. + await sendChunkedPush(diagnostic, payload, ctx, sessionId, sleep); } catch (err) { onEvent({ type: 'diagnostic_push_failed', code: 'LOOP_EXCEEDED', sessionId, cause: err }); } diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index 957502a..8e3ed23 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -308,15 +308,8 @@ export function validateInstantPayload(payload, opts) { }; } - const splitErr = validateSplitPattern(payload.splitPattern); - if (splitErr) { - return { - valid: false, - errorCode: 'INVALID_PAYLOAD_FORMAT', - errorMessage: splitErr, - details: { invalidFields: ['splitPattern'] } - }; - } + const splitErr = validatePerKindSplitPatterns(payload); + if (splitErr) return splitErr; // Hook-path-only fields are validated regardless of which path // we're on — even legacy callers passing them should get a clean @@ -453,9 +446,40 @@ export function validateContinuePayload(payload, opts) { payload.avatarUrl = null; } + const splitErr = validatePerKindSplitPatterns(payload); + if (splitErr) return splitErr; + return validateHookPathSharedFields(payload, opts) || { valid: true }; } +/** + * Validate the three per-kind split-pattern fields. All three follow + * the same shape rules (`validateSplitPattern`) — only their + * semantics differ at runtime (`content` / `tool_request` default-on, + * `reasoning` / `error` default-off). + * + * @param {Object} payload + */ +function validatePerKindSplitPatterns(payload) { + for (const field of ['splitPattern', 'reasoningSplitPattern', 'errorSplitPattern']) { + const err = validateSplitPattern(payload[field]); + if (err) { + // Re-label the error with the actual field name so the caller + // knows which knob to fix. `validateSplitPattern` itself uses + // the literal label "splitPattern" / "splitPattern[i]"; rewrite + // it for the per-kind fields. + const labelled = field === 'splitPattern' ? err : err.replace(/^splitPattern/, field); + return { + valid: false, + errorCode: 'INVALID_PAYLOAD_FORMAT', + errorMessage: labelled, + details: { invalidFields: [field] }, + }; + } + } + return null; +} + /** * Validate fields that are only meaningful on the hook path (or * `/continue`). Returns null when everything looks fine so the diff --git a/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs b/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs new file mode 100644 index 0000000..e7491e6 --- /dev/null +++ b/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs @@ -0,0 +1,1267 @@ +/** + * next.2 — splitPattern in hook mode. + * + * 0.7 / 0.8.0-next.1 disabled `splitPattern` on the hook path and + * emitted a startup warn whenever both `onLLMOutput` and `splitPattern` + * were set. This file pins the next.2 behaviour: + * + * - splitPattern applies to the kind-specific text field of the + * pushPayload returned by the hook (`content.message`, + * `reasoning.reasoningContent`, `tool_request.message`). + * - Default `/([。!?!?]+)/` mirrors the legacy path. + * - `null` / `[]` opt out. + * - Each chunk gets a fresh `messageId` + 1-based `messageIndex` + + * `totalMessages`, shares `sessionId`, copies `metadata` verbatim. + * - ToolRequestPush splitting demotes prefix chunks to ContentPush + * (drops `toolCalls`) and binds `toolCalls` to the chunk holding + * the LAST prefix segment (kept as `tool_request`). + * - Chunks are serialised with `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) + * spacing — same constant as the legacy path. + * - The "splitPattern is ignored" startup warn is gone. + * - Non-hook path is untouched (0.6 regression covered by + * handler.test.mjs; we re-assert one case here to be loud about + * it). + * + * Most tests drive `processInstantMessage` directly so we can inject a + * `sleep` mock — running real 1500 ms × (N-1) waits through the public + * handler would balloon the suite. The handler-level wire-up is covered + * by one end-to-end test that does pay the wall-clock cost. + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createInstantHandler, + processInstantMessage, +} from '../src/index.js'; +import { + generateTestVapid, + generateTestSubscription, + createFetchRouter, + decryptCapturedPushBody, + makeLlmResponse, +} from './helpers.mjs'; + +const LLM_URL = 'https://api.example.com/v1/chat/completions'; + +let vapid; +let subKit; + +before(async () => { + vapid = await generateTestVapid(); + subKit = await generateTestSubscription(); +}); + +function basePayload(overrides = {}) { + return { + contactName: 'Rei', + messages: [{ role: 'user', content: 'kick the loop' }], + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + sessionId: 'sess-split', + ...overrides, + }; +} + +function makeRequest(url, body, headers = {}) { + return new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +/** Drive the processor directly with an instant-sleep mock + sleep tracker. */ +async function runProcessor(payload, hookCtxOverrides = {}) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: hookCtxOverrides.llm || (() => makeLlmResponse('llm-output')), + }); + const sleeps = []; + const events = []; + const ctx = { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onEvent: (e) => events.push(e), + onLLMOutput: hookCtxOverrides.onLLMOutput, + requestUrl: 'http://localhost/instant', + autoEmitReasoning: hookCtxOverrides.autoEmitReasoning, + reasoningChunkBytes: hookCtxOverrides.reasoningChunkBytes, + blobStore: hookCtxOverrides.blobStore, + }; + const result = await processInstantMessage(payload, ctx); + const decoded = []; + for (const call of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + return { result, pushes: decoded, sleeps, events, router }; +} + +// ─── 1) hook + no splitPattern + default ContentPush ──────────────────── + +describe('hook mode + splitPattern — default sentence-split', () => { + it('splits content.message by default `/([。!?!?]+)/` into 3 pushes', async () => { + const { result, pushes, sleeps } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + metadata: { trace: 'xyz' }, + }, + }), + } + ); + assert.equal(result.status, 'finished'); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); + // wire format assertions + assert.equal(pushes[0].messageIndex, 1); + assert.equal(pushes[2].messageIndex, 3); + assert.equal(pushes[0].totalMessages, 3); + assert.equal(pushes[2].totalMessages, 3); + // shared sessionId, distinct messageIds, metadata copied + const sessionIds = new Set(pushes.map((p) => p.sessionId)); + assert.equal(sessionIds.size, 1); + const messageIds = new Set(pushes.map((p) => p.messageId)); + assert.equal(messageIds.size, 3); + assert.deepEqual(pushes.map((p) => p.metadata), [ + { trace: 'xyz' }, + { trace: 'xyz' }, + { trace: 'xyz' }, + ]); + // spacing: SLEEP_BETWEEN_MESSAGES_MS between every pair (N-1 sleeps) + assert.deepEqual(sleeps, [1500, 1500]); + }); +}); + +// ─── 2) explicit string splitPattern (same as default) ────────────────── + +describe('hook mode + splitPattern — explicit string', () => { + it('explicit `([。!?!?]+)` matches default behaviour', async () => { + const { pushes } = await runProcessor( + basePayload({ splitPattern: '([。!?!?]+)' }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); + }); +}); + +// ─── 3) array cascade ─────────────────────────────────────────────────── + +describe('hook mode + splitPattern — array cascade', () => { + it('splits by \\n+ first, then by sentence regex', async () => { + const { pushes } = await runProcessor( + basePayload({ splitPattern: ['\\n+', '([。!?!?]+)'] }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。\nC。D。', + }, + }), + } + ); + // First cascade: ['A。B。', 'C。D。'] (delimiter \n+ has no capture group → dropped) + // Second cascade: ['A。','B。','C。','D。'] + assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。', 'D。']); + assert.deepEqual(pushes.map((p) => p.messageIndex), [1, 2, 3, 4]); + assert.equal(pushes[0].totalMessages, 4); + }); +}); + +// ─── 4) splitPattern: null / [] disable splitting ─────────────────────── + +describe('hook mode + splitPattern — disable', () => { + it('splitPattern: null → single push (no split)', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload({ splitPattern: null }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'A。B。C。'); + assert.equal(pushes[0].messageId, 'hook-msg', 'single-chunk passthrough preserves original messageId'); + assert.deepEqual(sleeps, []); + }); + + it('splitPattern: [] → single push (no split)', async () => { + const { pushes } = await runProcessor( + basePayload({ splitPattern: [] }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'A。B。C。'); + }); +}); + +// ─── 5) no punctuation + default pattern → single push ────────────────── + +describe('hook mode + splitPattern — no match passes through', () => { + it('default regex on punctuation-free message → single push', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'no punctuation here at all', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'no punctuation here at all'); + assert.deepEqual(sleeps, []); + }); +}); + +// ─── 6) ReasoningPush — default off; opt-in via reasoningSplitPattern ─── + +describe('hook mode — ReasoningPush default off', () => { + it('reasoning is NOT split by default, even with sentence-laden content', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload(), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-reason-default', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'first thought。second thought。third。', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].reasoningContent, 'first thought。second thought。third。'); + assert.equal(pushes[0].messageId, 'hook-reason-default'); + assert.deepEqual(sleeps, []); + }); + + it('splitPattern alone does NOT split reasoning — reasoning has its own knob', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload({ splitPattern: '([。!?!?]+)' }), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-reason-default', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'first thought。second thought。third。', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.deepEqual(sleeps, []); + }); +}); + +describe('hook mode — reasoningSplitPattern enables reasoning splitting', () => { + it('reasoningSplitPattern: sentence regex → N reasoning pushes', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload({ reasoningSplitPattern: '([。!?!?]+)' }), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-reason', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'first thought。second thought。third。', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.messageKind), [ + 'reasoning', 'reasoning', 'reasoning', + ]); + assert.deepEqual(pushes.map((p) => p.reasoningContent), [ + 'first thought。', 'second thought。', 'third。', + ]); + assert.deepEqual(pushes.map((p) => p.messageIndex), [1, 2, 3]); + assert.deepEqual(pushes.map((p) => p.totalMessages), [3, 3, 3]); + assert.deepEqual(sleeps, [1500, 1500]); + }); + + it('reasoningSplitPattern: null / [] keep reasoning unsplit (explicit-off, same as undefined)', async () => { + for (const sp of [null, []]) { + const { pushes } = await runProcessor( + basePayload({ reasoningSplitPattern: sp }), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-reason', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'first thought。second thought。third。', + }, + }), + } + ); + assert.equal(pushes.length, 1, `reasoningSplitPattern: ${JSON.stringify(sp)}`); + } + }); + + it('reasoningSplitPattern cascade (string[]) is honoured', async () => { + const { pushes } = await runProcessor( + basePayload({ reasoningSplitPattern: ['\\n+', '([。!?!?]+)'] }), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-reason', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'A。B。\nC。D。', + }, + }), + } + ); + assert.deepEqual(pushes.map((p) => p.reasoningContent), ['A。', 'B。', 'C。', 'D。']); + }); +}); + +describe('hook mode — auto-emitted ReasoningPush also honours reasoningSplitPattern', () => { + it('framework-built reasoning from LLM splits when reasoningSplitPattern is set', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('final answer', { reasoning_content: 'step 1。step 2。step 3。' }), + }); + const sleeps = []; + const ctx = { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onLLMOutput: () => ({ decision: 'skip-push' }), + requestUrl: 'http://localhost/instant', + }; + await processInstantMessage( + basePayload({ reasoningSplitPattern: '([。!?!?]+)' }), + ctx + ); + const decoded = []; + for (const call of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + // 3 reasoning chunks, then hook skip-push → no content + assert.equal(decoded.length, 3); + assert.deepEqual(decoded.map((p) => p.messageKind), [ + 'reasoning', 'reasoning', 'reasoning', + ]); + assert.deepEqual(decoded.map((p) => p.reasoningContent), [ + 'step 1。', 'step 2。', 'step 3。', + ]); + // 2 sleeps between 3 chunks (auto-emit) — no post-burst sleep counted + // because the hook returned skip-push, but the legacy post-reasoning + // sleep before content burst still fires. + assert.deepEqual(sleeps.slice(0, 2), [1500, 1500]); + }); + + it('default (no reasoningSplitPattern) keeps auto-emit as a single reasoning push', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('final answer', { reasoning_content: 'step 1。step 2。step 3。' }), + }); + const ctx = { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onLLMOutput: () => ({ decision: 'skip-push' }), + requestUrl: 'http://localhost/instant', + }; + await processInstantMessage(basePayload(), ctx); + assert.equal(router.pushCalls.length, 1); + const decoded = JSON.parse(await decryptCapturedPushBody(router.pushCalls[0].body, subKit)); + assert.equal(decoded.messageKind, 'reasoning'); + assert.equal(decoded.reasoningContent, 'step 1。step 2。step 3。'); + }); +}); + +// ─── 7) ToolRequestPush — prefix chunks demote, toolCalls bind to last ── + +describe('hook mode + splitPattern — ToolRequestPush', () => { + it('N-1 chunks → ContentPush; final chunk → ToolRequestPush with toolCalls', async () => { + const toolCalls = [{ + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' }, + }]; + const { pushes, sleeps } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'tool-request', + pushPayload: { + messageKind: 'tool_request', + messageType: 'instant', + source: 'instant', + messageId: 'hook-tool', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'Let me check。One moment。Fetching now。', + toolCalls, + }, + }), + } + ); + assert.equal(pushes.length, 3); + // Prefix chunks: ContentPush, no toolCalls + assert.equal(pushes[0].messageKind, 'content'); + assert.equal(pushes[1].messageKind, 'content'); + assert.equal('toolCalls' in pushes[0], false); + assert.equal('toolCalls' in pushes[1], false); + assert.equal(pushes[0].message, 'Let me check。'); + assert.equal(pushes[1].message, 'One moment。'); + // Final chunk keeps tool_request kind + full toolCalls atomic + assert.equal(pushes[2].messageKind, 'tool_request'); + assert.equal(pushes[2].message, 'Fetching now。'); + assert.deepEqual(pushes[2].toolCalls, toolCalls); + // All share sessionId + assert.equal(new Set(pushes.map((p) => p.sessionId)).size, 1); + // 1-based messageIndex on every chunk + assert.deepEqual(pushes.map((p) => p.messageIndex), [1, 2, 3]); + assert.deepEqual(pushes.map((p) => p.totalMessages), [3, 3, 3]); + assert.deepEqual(sleeps, [1500, 1500]); + }); + + it('single-segment ToolRequestPush passes through unchanged (toolCalls intact)', async () => { + const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; + const { pushes } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'tool-request', + pushPayload: { + messageKind: 'tool_request', + messageType: 'instant', + source: 'instant', + messageId: 'hook-tool-single', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'no punctuation', + toolCalls, + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].messageKind, 'tool_request'); + assert.deepEqual(pushes[0].toolCalls, toolCalls); + assert.equal(pushes[0].messageId, 'hook-tool-single'); + }); + + it('ToolRequestPush without `message` is not split (no field to slice)', async () => { + const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; + const { pushes } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'tool-request', + pushPayload: { + messageKind: 'tool_request', + messageType: 'instant', + source: 'instant', + messageId: 'hook-tool-no-msg', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + toolCalls, + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].messageKind, 'tool_request'); + assert.deepEqual(pushes[0].toolCalls, toolCalls); + }); +}); + +// ─── 8) ErrorPush — default off; opt-in via errorSplitPattern ─────────── + +describe('hook mode — ErrorPush default off', () => { + it('error kind passes through verbatim even with sentence-laden message', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'error', + messageType: 'instant', + source: 'instant', + messageId: 'hook-err', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + code: 'CUSTOM_FAIL', + message: 'first sentence。second sentence。', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].messageKind, 'error'); + assert.equal(pushes[0].message, 'first sentence。second sentence。'); + assert.deepEqual(sleeps, []); + }); + + it('splitPattern alone does NOT split error — error has its own knob', async () => { + const { pushes } = await runProcessor( + basePayload({ splitPattern: '([。!?!?]+)' }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'error', + messageType: 'instant', + source: 'instant', + messageId: 'hook-err', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + code: 'CUSTOM_FAIL', + message: 'first sentence。second sentence。', + }, + }), + } + ); + assert.equal(pushes.length, 1); + }); +}); + +describe('hook mode — errorSplitPattern enables error splitting', () => { + it('errorSplitPattern: sentence regex → N error pushes', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload({ errorSplitPattern: '([。!?!?]+)' }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'error', + messageType: 'instant', + source: 'instant', + messageId: 'hook-err', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + code: 'CUSTOM_FAIL', + message: 'first sentence。second sentence。third sentence。', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.messageKind), ['error', 'error', 'error']); + assert.deepEqual(pushes.map((p) => p.message), [ + 'first sentence。', 'second sentence。', 'third sentence。', + ]); + // `code` and other top-level fields are preserved on every chunk. + assert.deepEqual(pushes.map((p) => p.code), [ + 'CUSTOM_FAIL', 'CUSTOM_FAIL', 'CUSTOM_FAIL', + ]); + assert.deepEqual(sleeps, [1500, 1500]); + }); + + it('errorSplitPattern: null / [] keep error unsplit (explicit-off)', async () => { + for (const sp of [null, []]) { + const { pushes } = await runProcessor( + basePayload({ errorSplitPattern: sp }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'error', + messageType: 'instant', + source: 'instant', + messageId: 'hook-err', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + code: 'CUSTOM_FAIL', + message: 'first sentence。second sentence。', + }, + }), + } + ); + assert.equal(pushes.length, 1, `errorSplitPattern: ${JSON.stringify(sp)}`); + } + }); +}); + +describe('hook mode — LOOP_EXCEEDED diagnostic respects errorSplitPattern', () => { + it('framework-built LOOP_EXCEEDED can be chunked when errorSplitPattern matches', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('again'), + }); + const sleeps = []; + const ctx = { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + maxLoopIterations: 2, + onLLMOutput: (sctx) => ({ decision: 'continue', nextHistory: sctx.messages }), + requestUrl: 'http://localhost/instant', + }; + // The framework message is "Agentic loop exceeded 2 iterations" — + // no sentence punctuation. Split by whitespace so we can prove the + // path runs through the splitter for ErrorPush too. + const result = await processInstantMessage( + basePayload({ errorSplitPattern: '(\\s+)' }), + ctx + ); + assert.equal(result.status, 'loop_exceeded'); + // "Agentic loop exceeded 2 iterations" → 4 tokens with the + // capture-group-spaces convention. Just check >1 to keep the + // assertion robust against future wording tweaks. + assert.ok(router.pushCalls.length >= 2, `expected ≥2 chunks, got ${router.pushCalls.length}`); + const decoded = []; + for (const call of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + assert.equal(decoded[0].messageKind, 'error'); + assert.equal(decoded[0].code, 'LOOP_EXCEEDED'); + }); +}); + +// ─── 9) Free-form pushPayload is never split ──────────────────────────── + +describe('hook mode + splitPattern — free-form payload opts out', () => { + it('payload without `messageKind` passes through verbatim', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload(), + { + onLLMOutput: () => ({ + decision: 'finish', + // No messageKind → framework can't guess which field to split. + pushPayload: { type: 'legacy', text: 'A。B。C。' }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].text, 'A。B。C。'); + assert.equal(pushes[0].type, 'legacy'); + assert.deepEqual(sleeps, []); + }); +}); + +// ─── 10) ordering: pushes ship strictly serially with the sleep gap ───── + +describe('hook mode + splitPattern — serial ordering', () => { + it('emits pushes in 1..N order interleaved with sleeps', async () => { + const sequence = []; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('x'), + onPush: () => { sequence.push('push'); return undefined; }, + }); + const ctx = { + vapid, + fetch: router.fetch, + sleep: (ms) => { sequence.push(`sleep:${ms}`); return Promise.resolve(); }, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'hook-msg', + sessionId: sctx.sessionId, + message: 'A。B。C。', + }, + }), + requestUrl: 'http://localhost/instant', + }; + await processInstantMessage(basePayload(), ctx); + assert.deepEqual(sequence, [ + 'push', 'sleep:1500', + 'push', 'sleep:1500', + 'push', + ]); + }); +}); + +// ─── 11) no startup warn about splitPattern + onLLMOutput combo ───────── + +describe('createInstantHandler — no warn about splitPattern in hook mode', () => { + it('does NOT emit "splitPattern is ignored" warning anymore', () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => { warnings.push(args.join(' ')); }; + try { + createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + // Construct a second handler that explicitly passes splitPattern + // via the request payload path — there's no handler-level option, + // so the only thing that could have warned was the old 0.7 block. + createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + } finally { + console.warn = origWarn; + } + const offending = warnings.find((w) => w.includes('splitPattern is ignored')); + assert.equal(offending, undefined, `unexpected warn: ${offending}`); + }); +}); + +// ─── 12) legacy path regression — splitPattern still works without hook ─ + +describe('non-hook regression — splitPattern still drives sentence-burst', () => { + it('legacy path with default splitPattern still emits N content pushes', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('A。B。C。'), + }); + const sleeps = []; + const ctx = { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + // No onLLMOutput → legacy path. + requestUrl: 'http://localhost/instant', + }; + const payload = { + contactName: 'Rei', + completePrompt: 'say A B C', + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + }; + const result = await processInstantMessage(payload, ctx); + assert.equal(result.messagesSent, 3); + assert.equal(router.pushCalls.length, 3); + const decoded = []; + for (const call of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + assert.deepEqual(decoded.map((p) => p.message), ['A。', 'B。', 'C。']); + assert.deepEqual(sleeps, [1500, 1500]); + }); +}); + +// ─── validation: per-kind split-pattern fields ────────────────────────── + +describe('validation — reasoningSplitPattern + errorSplitPattern', () => { + it('rejects reasoningSplitPattern that is not string / string[]', async () => { + const handler = createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + const res = await handler(makeRequest( + 'http://h/instant', + basePayload({ reasoningSplitPattern: 42 }) + )); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error.code, 'INVALID_PAYLOAD_FORMAT'); + assert.deepEqual(body.error.details.invalidFields, ['reasoningSplitPattern']); + assert.ok(body.error.message.includes('reasoningSplitPattern')); + }); + + it('rejects errorSplitPattern with un-compilable regex', async () => { + const handler = createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + const res = await handler(makeRequest( + 'http://h/instant', + basePayload({ errorSplitPattern: '(' /* unbalanced group */ }) + )); + assert.equal(res.status, 400); + const body = await res.json(); + assert.deepEqual(body.error.details.invalidFields, ['errorSplitPattern']); + }); + + it('accepts valid string / string[] / null / [] / undefined', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('x'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + }); + for (const sp of [undefined, null, [], '([。!?!?]+)', ['\\n+', '([。!?!?]+)']]) { + const res = await handler(makeRequest( + 'http://h/instant', + basePayload({ reasoningSplitPattern: sp, errorSplitPattern: sp }) + )); + assert.equal(res.status, 200, `reasoningSplitPattern: ${JSON.stringify(sp)}`); + } + }); +}); + +// ─── 13) handler-level wiring smoke test ──────────────────────────────── +// +// Spends one real 1500 ms sleep through `setTimeout` to prove the +// public handler entry-point honours splitPattern end-to-end. Only one +// chunk-gap so the suite cost is bounded. + +describe('handler entry-point — wires through splitPattern end-to-end', () => { + it('createInstantHandler + hook + 2-chunk message → 2 pushes', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('x'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'wire-msg', + sessionId: sctx.sessionId, + message: 'first half。second half。', + }, + }), + }); + const start = Date.now(); + const res = await handler(makeRequest('http://h/instant', basePayload())); + const elapsed = Date.now() - start; + assert.equal(res.status, 200); + assert.equal(router.pushCalls.length, 2); + // Real 1.5s gap between two pushes. Allow generous slack (CI). + assert.ok(elapsed >= 1400, `expected ≥1.4s wall, got ${elapsed}ms`); + }); +}); + +// ─── reasoning byte chunking (next.2 Layer 2) ─────────────────────────── + +describe('reasoning byte chunking — defaults', () => { + it('short reasoning (< 2000 B) ships as a single push, no chunkIndex fields', async () => { + const { pushes } = await runProcessor( + basePayload(), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-small', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'short thought', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.ok(!('chunkIndex' in pushes[0])); + assert.ok(!('totalChunks' in pushes[0])); + // Single-chunk passthrough preserves original messageId (no regen). + assert.equal(pushes[0].messageId, 'hook-small'); + }); + + it('6 KB ASCII reasoning at default 2000 B → 3 chunks with chunkIndex 1..3', async () => { + const big = 'a'.repeat(6000); + const { pushes, sleeps, events } = await runProcessor( + basePayload(), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-big', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: big, + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.messageKind), ['reasoning', 'reasoning', 'reasoning']); + assert.deepEqual(pushes.map((p) => p.chunkIndex), [1, 2, 3]); + assert.deepEqual(pushes.map((p) => p.totalChunks), [3, 3, 3]); + // Reassembled content must equal the input — no data loss. + assert.equal(pushes.map((p) => p.reasoningContent).join(''), big); + // Each chunk's reasoningContent UTF-8 bytes ≤ threshold. + const enc = new TextEncoder(); + for (const p of pushes) assert.ok(enc.encode(p.reasoningContent).byteLength <= 2000); + // 100ms gap between Layer-2 chunks of the same Layer-1 segment. + assert.deepEqual(sleeps, [100, 100]); + // One `reasoning_chunked` event fired. + const chunkedEvents = events.filter((e) => e.type === 'reasoning_chunked'); + assert.equal(chunkedEvents.length, 1); + assert.equal(chunkedEvents[0].totalChunks, 3); + assert.equal(chunkedEvents[0].totalBytes, 6000); + assert.equal(chunkedEvents[0].sessionId, 'sess-split'); + }); + + it('CJK 1500-char reasoning (~4500 B) at default threshold → safe codepoint boundaries', async () => { + const cjk = '寿'.repeat(1500); + const { pushes } = await runProcessor( + basePayload(), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-cjk', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: cjk, + }, + }), + } + ); + assert.ok(pushes.length >= 3); + assert.equal(pushes.map((p) => p.reasoningContent).join(''), cjk); + // Every chunk decodes cleanly — no garbled half-character residue. + for (const p of pushes) { + assert.ok(typeof p.reasoningContent === 'string' && p.reasoningContent.length > 0); + } + }); + + it('emoji (4-byte char) reasoning chunks at codepoint boundary', async () => { + const text = '🙂'.repeat(800); // 800 × 4 = 3200 B → ≥2 chunks at 2000 B threshold + const { pushes } = await runProcessor( + basePayload(), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-emoji', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: text, + }, + }), + } + ); + assert.ok(pushes.length >= 2); + assert.equal(pushes.map((p) => p.reasoningContent).join(''), text); + }); +}); + +describe('reasoning byte chunking — cascade with reasoningSplitPattern', () => { + it('sentence-split first, oversized sentences then byte-chunked', async () => { + // Three sentences. Sentence 2 is 5 KB → should byte-chunk into 3. + // Sentences 1 and 3 are short → stay single. + const big = 'b'.repeat(5000); + const text = `start。${big}。end。`; + const { pushes, sleeps } = await runProcessor( + basePayload({ reasoningSplitPattern: '([。!?!?]+)' }), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-cascade', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: text, + }, + }), + } + ); + // Layer 1 produces 3 segments. Segment 2 is oversized → byte-chunks + // into 3. Final leaf count: 1 + 3 + 1 = 5. + assert.equal(pushes.length, 5); + + // First leaf: Layer 1 segment 1, no byte chunking. + assert.equal(pushes[0].messageIndex, 1); + assert.equal(pushes[0].totalMessages, 3); + assert.ok(!('chunkIndex' in pushes[0])); + assert.equal(pushes[0].reasoningContent, 'start。'); + + // Leaves 2..4: Layer 1 segment 2, byte-chunked into 3. + for (let i = 1; i <= 3; i++) { + assert.equal(pushes[i].messageIndex, 2); + assert.equal(pushes[i].totalMessages, 3); + assert.equal(pushes[i].chunkIndex, i); + assert.equal(pushes[i].totalChunks, 3); + } + // Byte-chunks concat back to the original Layer-1 segment 2. + assert.equal(pushes.slice(1, 4).map((p) => p.reasoningContent).join(''), big + '。'); + + // Last leaf: Layer 1 segment 3, no byte chunking. + assert.equal(pushes[4].messageIndex, 3); + assert.ok(!('chunkIndex' in pushes[4])); + assert.equal(pushes[4].reasoningContent, 'end。'); + + // Sleeps: 1500ms between Layer-1 segments, 100ms between Layer-2 chunks of segment 2. + // Sequence: send leaf 0 → 1500 (boundary) → leaf 1 → 100 → leaf 2 → 100 → leaf 3 → 1500 (boundary) → leaf 4. + assert.deepEqual(sleeps, [1500, 100, 100, 1500]); + }); +}); + +describe('reasoning byte chunking — disable knob', () => { + it('reasoningChunkBytes: null + big reasoning + no BlobStore → PAYLOAD_TOO_LARGE', async () => { + const big = 'a'.repeat(6000); + await assert.rejects( + runProcessor( + basePayload(), + { + reasoningChunkBytes: null, + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-big', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: big, + }, + }), + } + ), + (err) => err && err.code === 'PAYLOAD_TOO_LARGE' + ); + }); + + it('reasoningChunkBytes: null + big reasoning + BlobStore configured → 1 envelope push', async () => { + const { createMemoryBlobStore } = await import('../src/blob-store/memory.js'); + const blobAdapter = createMemoryBlobStore(); + const big = 'a'.repeat(6000); + const { pushes } = await runProcessor( + basePayload(), + { + reasoningChunkBytes: null, + blobStore: { adapter: blobAdapter }, + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'hook-big', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: big, + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0]._blob, true); + assert.equal(pushes[0].messageKind, 'reasoning'); + }); +}); + +describe('reasoning byte chunking — legacy path (no onLLMOutput)', () => { + it('legacy reasoning auto-emit chunks by bytes too', async () => { + const big = 'a'.repeat(6000); + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('final', { reasoning_content: big }), + }); + const sleeps = []; + const events = []; + const ctx = { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onEvent: (e) => events.push(e), + requestUrl: 'http://localhost/instant', + // No onLLMOutput → legacy path. + }; + const payload = { + contactName: 'Rei', + completePrompt: 'reason a lot', + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + }; + await processInstantMessage(payload, ctx); + const decoded = []; + for (const call of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + const reasonings = decoded.filter((p) => p.messageKind === 'reasoning'); + // 6000 B reasoning → 3 chunks of ≤ 2000 B each. + assert.equal(reasonings.length, 3); + assert.deepEqual(reasonings.map((p) => p.chunkIndex), [1, 2, 3]); + assert.equal(reasonings.map((p) => p.reasoningContent).join(''), big); + // The `reasoning_chunked` event fired on the legacy path too (no iteration). + const chunkedEvents = events.filter((e) => e.type === 'reasoning_chunked'); + assert.equal(chunkedEvents.length, 1); + assert.ok(!('iteration' in chunkedEvents[0])); + }); +}); + +describe('reasoning byte chunking — handler-level validation', () => { + it('throws TypeError when reasoningChunkBytes is 0 / negative / non-integer / too big', async () => { + for (const bad of [0, -1, 1.5, NaN, 'big', {}, []]) { + assert.throws( + () => createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + reasoningChunkBytes: bad, + }), + TypeError, + `expected TypeError for reasoningChunkBytes=${JSON.stringify(bad)}` + ); + } + }); + + it('throws when reasoningChunkBytes exceeds maxInlineBytes - 600 margin', async () => { + // Default maxInlineBytes = 2600 → upper bound = 2000. 2001 should throw. + assert.throws( + () => createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + reasoningChunkBytes: 2001, + }), + TypeError, + ); + }); + + it('accepts undefined (default 2000), null (disable), and in-range positive integer', async () => { + for (const v of [undefined, null, 500, 1000, 2000]) { + assert.doesNotThrow( + () => createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + reasoningChunkBytes: v, + }), + `should accept reasoningChunkBytes=${JSON.stringify(v)}`, + ); + } + }); + + it('uses blobStore.maxInlineBytes to compute the upper bound', async () => { + // Custom blobStore with maxInlineBytes 4096 → upper bound = 3496. + const customBlob = { adapter: { put: async () => {}, read: async () => null }, maxInlineBytes: 4096 }; + // 3000 should be accepted with the wider cap (it'd be over the default 2000 cap). + assert.doesNotThrow( + () => createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + blobStore: customBlob, + reasoningChunkBytes: 3000, + }), + ); + // 3497 still over the upper bound. + assert.throws( + () => createInstantHandler({ + vapid, + fetch: globalThis.fetch, + onLLMOutput: () => ({ decision: 'skip-push' }), + blobStore: customBlob, + reasoningChunkBytes: 3497, + }), + TypeError, + ); + }); +}); diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md index 9dd43bb..db8ce95 100644 --- a/packages/rei-standard-amsg/shared/CHANGELOG.md +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -1,5 +1,21 @@ # @rei-standard/amsg-shared +## 0.1.0-next.2 — ReasoningPush 字节切分 + multi-part 索引字段 (pre-release) + +Coordinated with `@rei-standard/amsg-instant@0.8.0-next.2`. Install with `npm install @rei-standard/amsg-shared@next`. Existing single-shot ReasoningPush callers are wire-compatible — the new fields are emitted only when chunking actually fires. + +### New + +- **`ReasoningPush` 加四个可选字段**:`messageIndex` / `totalMessages`(语义切,由 amsg-instant 的 `reasoningSplitPattern` 触发)+ `chunkIndex` / `totalChunks`(字节切,由 amsg-instant 的 `reasoningChunkBytes` 触发,把单段 reasoning 在 UTF-8 codepoint 边界切成 N 份绕开 Web Push ~2.6 KB 上限)。四个字段都 optional,单 chunk 单 segment 时不写到 wire 上,老 SW 看到的字节流跟 next.1 完全一致。 +- **`buildReasoningPush`** 透传四个新可选字段;未传时输出不包含它们。 +- **新导出 `chunkReasoningByUtf8Bytes(text, maxBytes)`** — UTF-8 codepoint-safe 字节切分 helper。`TextEncoder` → 字节扫描回退到 lead byte → `TextDecoder` 还原。汉字(3-byte)/ emoji(4-byte)/ ASCII 混排都能保证边界不切坏,`chunks.join('')` 严格等于输入。`maxBytes < 4` 抛 `RangeError`(UTF-8 codepoint 最宽 4 字节,更小没法切);非字符串 `text` 抛 `TypeError`。 +- **SW / 消费方拼接约定**(仅文档,本包不实现):按 `sessionId` 分桶 → 有 `messageIndex` 再按它分子桶(Layer 1)→ 按 `chunkIndex` 排序拼字符串(Layer 2)。两个轴都到齐再消费。 + +### Unchanged + +- 三轴 push schema、其它三种 push(content / tool_request / error)的 typedef + 字段、type guard、`MESSAGE_KIND` / `MESSAGE_TYPE` / `PUSH_SOURCE` 常量、零运行时依赖、ESM/CJS 双发布 — 全不动。 +- 单 chunk 单 segment 的 ReasoningPush wire format 完全不变(新字段默认不写)。 + ## 0.1.0-next.0 — initial pre-release Published under the `next` dist-tag (the repo's convention for prereleases — `publish-workspaces.mjs` auto-routes any version with a prerelease suffix). The schema is locked but the package is held back from `latest` until downstream integrators sign off on the wire shape end-to-end. Install with `npm install @rei-standard/amsg-shared@next`. diff --git a/packages/rei-standard-amsg/shared/package.json b/packages/rei-standard-amsg/shared/package.json index 796ce97..9fdbf3e 100644 --- a/packages/rei-standard-amsg/shared/package.json +++ b/packages/rei-standard-amsg/shared/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.0", + "version": "0.1.0-next.2", "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/shared/src/index.js b/packages/rei-standard-amsg/shared/src/index.js index ef16b87..1efac43 100644 --- a/packages/rei-standard-amsg/shared/src/index.js +++ b/packages/rei-standard-amsg/shared/src/index.js @@ -121,17 +121,35 @@ export const PUSH_SOURCE = Object.freeze({ * out of the upstream response into its own push. Emitted **before** * the matching {@link ContentPush} burst when present and non-empty. * - * Intentionally does NOT carry `messageIndex` / `totalMessages` — - * reasoning is a single push per LLM round, never a split-burst. - * That's why those fields are absent at the type level rather than - * `optional` (which would leave callers wondering when they're set). + * Reasoning carries two orthogonal "multi-part" axes, both optional — + * they are *omitted* when the part count is 1 so the wire stays + * byte-for-byte compatible with single-shot ReasoningPush callers: + * + * - `messageIndex` / `totalMessages` — set when a semantic + * splitter (`reasoningSplitPattern` in amsg-instant) has cut the + * reasoning into multiple sentences for typing-bubble UX. + * + * - `chunkIndex` / `totalChunks` — set when a single segment was + * too large for the Web Push payload limit and the producer had + * to slice it across multiple pushes at UTF-8 byte boundaries. + * Transport-only; SW reassembles the original `reasoningContent` + * by sorting on `chunkIndex` within a `(sessionId, messageIndex)` + * bucket. See `chunkReasoningByUtf8Bytes` for the safe-edge + * splitter helper. + * + * Both axes can coexist on the same push when a sentence-split + * segment is itself oversized. * * @typedef {AmsgPushCommon & { * messageKind: 'reasoning', * reasoningContent: string, - * title?: string, - * contactName?: string, - * avatarUrl?: string | null, + * title?: string, + * contactName?: string, + * avatarUrl?: string | null, + * messageIndex?: number, + * totalMessages?: number, + * chunkIndex?: number, + * totalChunks?: number, * }} ReasoningPush */ @@ -257,8 +275,16 @@ export function buildContentPush(args) { * matching `ContentPush` burst when the LLM response carried a non- * empty `reasoning_content`. * - * Does NOT take `messageIndex` / `totalMessages` — reasoning is one - * push per LLM round. + * Two optional multi-part axes (both omitted from wire when the part + * count is 1, so single-shot reasoning stays byte-for-byte compatible): + * + * - `messageIndex` / `totalMessages` — semantic splitter (sentence + * regex) produced multiple segments. + * - `chunkIndex` / `totalChunks` — byte splitter (UTF-8 payload-limit + * workaround) sliced a single segment across multiple pushes. + * + * Both can be set together when a sentence-split segment is itself + * oversized. See README §"Reasoning chunking". * * @param {Object} args * @param {MessageType} args.messageType @@ -271,6 +297,10 @@ export function buildContentPush(args) { * @param {string} [args.contactName] * @param {string | null} [args.avatarUrl] * @param {string} [args.messageSubtype] + * @param {number} [args.messageIndex] + * @param {number} [args.totalMessages] + * @param {number} [args.chunkIndex] + * @param {number} [args.totalChunks] * @param {Object} [args.metadata] * @returns {ReasoningPush} */ @@ -297,6 +327,10 @@ export function buildReasoningPush(args) { if (args.contactName !== undefined) push.contactName = args.contactName; if (args.avatarUrl !== undefined) push.avatarUrl = args.avatarUrl; if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; + if (args.messageIndex !== undefined) push.messageIndex = args.messageIndex; + if (args.totalMessages !== undefined) push.totalMessages = args.totalMessages; + if (args.chunkIndex !== undefined) push.chunkIndex = args.chunkIndex; + if (args.totalChunks !== undefined) push.totalChunks = args.totalChunks; if (args.metadata !== undefined) push.metadata = args.metadata; return push; } @@ -438,3 +472,80 @@ export function isErrorPush(value) { return !!value && typeof value === 'object' && /** @type {{messageKind?: unknown}} */ (value).messageKind === 'error'; } + +// ─── Reasoning byte chunker ───────────────────────────────────────────── + +const REASONING_CHUNK_ENCODER = new TextEncoder(); +const REASONING_CHUNK_DECODER = new TextDecoder('utf-8', { fatal: true }); + +/** + * Slice a string into UTF-8 byte chunks no larger than `maxBytes`, + * always cutting at codepoint boundaries (never inside a multi-byte + * char). Designed for the {@link ReasoningPush} byte-chunking path + * in amsg-instant — producers facing the ~3 KB Web Push payload + * limit slice oversized reasoning into N pushes with + * `chunkIndex` / `totalChunks`, the SW reassembles by concat. + * + * Algorithm: TextEncoder → Uint8Array → backward scan from each + * candidate cut index until the byte is a UTF-8 lead byte (any byte + * where `(b & 0xC0) !== 0x80`; continuation bytes are `0b10xxxxxx`). + * TextDecoder turns each slice back into a JS string. + * + * chunkReasoningByUtf8Bytes('A寿B', 4) → ['A寿', 'B'] // '寿' = 3 B, + * // cut at safe edge + * + * Constraints: + * - `maxBytes` MUST be ≥ 4 (UTF-8 codepoints can be up to 4 bytes; + * any smaller threshold has no valid cut point for a 4-byte char + * and is also operationally nonsensical). Throws `RangeError` + * otherwise. + * - Empty `text` → `[]` (caller can check `.length === 0`). + * - `text` whose total UTF-8 byte length ≤ `maxBytes` → `[text]` + * (no chunking). + * - `text` MUST be a string. Non-string throws `TypeError`. + * + * Joining the result `chunks.join('')` is guaranteed to equal the + * input `text` (no data loss, no extra whitespace). + * + * @param {string} text + * @param {number} maxBytes + * @returns {string[]} + */ +export function chunkReasoningByUtf8Bytes(text, maxBytes) { + if (typeof text !== 'string') { + throw new TypeError('[amsg-shared] chunkReasoningByUtf8Bytes: text must be a string'); + } + if (!Number.isInteger(maxBytes) || maxBytes < 4) { + throw new RangeError( + '[amsg-shared] chunkReasoningByUtf8Bytes: maxBytes must be an integer ≥ 4 (UTF-8 max codepoint width)' + ); + } + if (text.length === 0) return []; + + const bytes = REASONING_CHUNK_ENCODER.encode(text); + if (bytes.byteLength <= maxBytes) return [text]; + + /** @type {string[]} */ + const chunks = []; + let start = 0; + while (start < bytes.byteLength) { + let end = Math.min(start + maxBytes, bytes.byteLength); + + if (end < bytes.byteLength) { + // Walk back to a lead byte. UTF-8 continuation bytes are + // `0b10xxxxxx` → (b & 0xC0) === 0x80. Any other byte starts a + // new codepoint, so `end` is a safe boundary as long as the + // byte AT `end` is NOT a continuation byte. + while (end > start && (bytes[end] & 0xC0) === 0x80) { + end--; + } + // The precondition `maxBytes ≥ 4` guarantees `end > start` + // here: a window of ≥4 bytes always contains at least one + // lead byte (UTF-8 codepoints are ≤ 4 bytes). + } + + chunks.push(REASONING_CHUNK_DECODER.decode(bytes.subarray(start, end))); + start = end; + } + return chunks; +} diff --git a/packages/rei-standard-amsg/shared/test/builders.test.mjs b/packages/rei-standard-amsg/shared/test/builders.test.mjs index d626137..73a1153 100644 --- a/packages/rei-standard-amsg/shared/test/builders.test.mjs +++ b/packages/rei-standard-amsg/shared/test/builders.test.mjs @@ -13,6 +13,7 @@ import { isReasoningPush, isToolRequestPush, isErrorPush, + chunkReasoningByUtf8Bytes, } from '../src/index.js'; const COMMON = Object.freeze({ @@ -72,18 +73,51 @@ test('buildContentPush forwards passthrough metadata without mutating', () => { assert.equal(push.metadata, metadata); }); -test('buildReasoningPush returns a ReasoningPush without index/total fields', () => { +test('buildReasoningPush returns a ReasoningPush without index/total/chunk fields by default', () => { const push = buildReasoningPush({ ...COMMON, reasoningContent: 'thinking out loud', }); assert.equal(push.messageKind, 'reasoning'); assert.equal(push.reasoningContent, 'thinking out loud'); + // None of the multi-part axes are present when the caller didn't + // explicitly pass them — keeps the single-shot wire byte-for-byte + // compatible with pre-byte-chunking ReasoningPush callers. assert.ok(!('messageIndex' in push)); assert.ok(!('totalMessages' in push)); + assert.ok(!('chunkIndex' in push)); + assert.ok(!('totalChunks' in push)); assert.ok(isReasoningPush(push)); }); +test('buildReasoningPush carries chunkIndex / totalChunks when explicitly passed', () => { + const push = buildReasoningPush({ + ...COMMON, + reasoningContent: 'first 2000 bytes…', + chunkIndex: 1, + totalChunks: 3, + }); + assert.equal(push.chunkIndex, 1); + assert.equal(push.totalChunks, 3); +}); + +test('buildReasoningPush carries both messageIndex/totalMessages and chunkIndex/totalChunks (cascade)', () => { + // The cascade case: sentence-split produced 3 segments, segment 2 + // was itself oversized and got byte-chunked into 5 sub-pushes. + const push = buildReasoningPush({ + ...COMMON, + reasoningContent: 'middle of sentence 2…', + messageIndex: 2, + totalMessages: 3, + chunkIndex: 1, + totalChunks: 5, + }); + assert.equal(push.messageIndex, 2); + assert.equal(push.totalMessages, 3); + assert.equal(push.chunkIndex, 1); + assert.equal(push.totalChunks, 5); +}); + test('buildReasoningPush rejects empty reasoningContent', () => { assert.throws( () => buildReasoningPush({ ...COMMON, reasoningContent: '' }), @@ -171,3 +205,86 @@ test('required fields reject empty string (not just undefined) for ID-shaped fie assert.throws(() => buildReasoningPush({ ...COMMON, messageId: '', reasoningContent: 'y' }), /'messageId' is required/); assert.throws(() => buildErrorPush({ ...COMMON, code: '', message: 'm' }), /'code' is required/); }); + +// ─── chunkReasoningByUtf8Bytes ────────────────────────────────────────── + +test('chunkReasoningByUtf8Bytes — empty string returns []', () => { + assert.deepEqual(chunkReasoningByUtf8Bytes('', 100), []); +}); + +test('chunkReasoningByUtf8Bytes — text under threshold returns single chunk', () => { + const text = 'hello world'; // 11 bytes ASCII + assert.deepEqual(chunkReasoningByUtf8Bytes(text, 100), [text]); +}); + +test('chunkReasoningByUtf8Bytes — text exactly at threshold returns single chunk', () => { + const text = 'a'.repeat(100); // 100 bytes ASCII + assert.deepEqual(chunkReasoningByUtf8Bytes(text, 100), [text]); +}); + +test('chunkReasoningByUtf8Bytes — ASCII over threshold splits into N chunks (joined = original)', () => { + const text = 'a'.repeat(250); // 250 bytes ASCII + const chunks = chunkReasoningByUtf8Bytes(text, 100); + assert.equal(chunks.length, 3); + assert.equal(chunks[0].length, 100); + assert.equal(chunks[1].length, 100); + assert.equal(chunks[2].length, 50); + assert.equal(chunks.join(''), text); +}); + +test('chunkReasoningByUtf8Bytes — pure CJK boundaries always at codepoint (寿)', () => { + // '寿' = 3 UTF-8 bytes. 1000 chars × 3 = 3000 bytes total. + // maxBytes 999 = 333 chars exactly. With maxBytes 1000 = 333.33 → + // 333 chars (999 bytes) per chunk, rest trails. Either way every + // boundary must hit a codepoint edge — no half-character. + const text = '寿'.repeat(1000); + const chunks = chunkReasoningByUtf8Bytes(text, 999); + assert.ok(chunks.length >= 3); + // Reconstruction safety — every chunk decodes cleanly + concat matches. + assert.equal(chunks.join(''), text); + // No chunk exceeds the byte cap. + const encoder = new TextEncoder(); + for (const c of chunks) { + assert.ok(encoder.encode(c).byteLength <= 999, `chunk byte len ${encoder.encode(c).byteLength}`); + } +}); + +test('chunkReasoningByUtf8Bytes — pure emoji (4-byte chars) never cuts inside surrogate', () => { + // '🙂' = 4 UTF-8 bytes (U+1F642, outside the BMP). + // 500 × 4 = 2000 bytes total. maxBytes 1003 → not a multiple of 4, + // so the splitter MUST walk back to the previous lead byte for at + // least one boundary. Joined chunks must still round-trip exactly. + const text = '🙂'.repeat(500); + const chunks = chunkReasoningByUtf8Bytes(text, 1003); + assert.ok(chunks.length >= 2); + assert.equal(chunks.join(''), text); + const encoder = new TextEncoder(); + for (const c of chunks) { + assert.ok(encoder.encode(c).byteLength <= 1003); + } +}); + +test('chunkReasoningByUtf8Bytes — mixed ASCII + CJK + emoji round-trips at various caps', () => { + const text = 'Hello 你好 🙂 worldこんにちは🌏'.repeat(20); + for (const cap of [50, 100, 256, 500, 1024]) { + const chunks = chunkReasoningByUtf8Bytes(text, cap); + assert.equal(chunks.join(''), text, `cap=${cap}`); + const encoder = new TextEncoder(); + for (const c of chunks) { + assert.ok(encoder.encode(c).byteLength <= cap, `cap=${cap}, chunk too big`); + } + } +}); + +test('chunkReasoningByUtf8Bytes — rejects maxBytes < 4 (no valid cut for 4-byte chars)', () => { + assert.throws(() => chunkReasoningByUtf8Bytes('hi', 3), /maxBytes must be an integer ≥ 4/); + assert.throws(() => chunkReasoningByUtf8Bytes('hi', 0), /maxBytes must be an integer ≥ 4/); + assert.throws(() => chunkReasoningByUtf8Bytes('hi', -1), /maxBytes must be an integer ≥ 4/); + assert.throws(() => chunkReasoningByUtf8Bytes('hi', 1.5), /maxBytes must be an integer ≥ 4/); +}); + +test('chunkReasoningByUtf8Bytes — rejects non-string text', () => { + assert.throws(() => chunkReasoningByUtf8Bytes(null, 100), /text must be a string/); + assert.throws(() => chunkReasoningByUtf8Bytes(undefined, 100), /text must be a string/); + assert.throws(() => chunkReasoningByUtf8Bytes(42, 100), /text must be a string/); +}); From 2ce7de5dda6a98e4d6dc394b593ec73d899dfaec Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Wed, 20 May 2026 23:32:26 +0800 Subject: [PATCH 07/33] chore(amsg): sync package-lock with amsg-shared@0.1.0-next.2 bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm ci` failed in the next.2 release workflow because the previous commit bumped amsg-instant's amsg-shared dependency string (next.0 → next.2) but didn't refresh the root package-lock.json. Run `npm install` to update the lockfile's amsg-instant workspace entry to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c73609e..fee95fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1863,12 +1863,21 @@ "node": ">=20" } }, + "packages/rei-standard-amsg/client/node_modules/@rei-standard/amsg-shared": { + "version": "0.1.0-next.0", + "resolved": "https://registry.npmjs.org/@rei-standard/amsg-shared/-/amsg-shared-0.1.0-next.0.tgz", + "integrity": "sha512-zgGGGWUh+h8zcSOYghh7vBrMQCQgSbgl0TeShGDH/LT8yWlViRrrFTJHCaVsmqtRu6v9nDHedMDavqv2NXz8JQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.1", + "version": "0.8.0-next.2", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0" + "@rei-standard/amsg-shared": "0.1.0-next.2" }, "devDependencies": { "tsup": "^8.0.0", @@ -1909,9 +1918,18 @@ } } }, + "packages/rei-standard-amsg/server/node_modules/@rei-standard/amsg-shared": { + "version": "0.1.0-next.0", + "resolved": "https://registry.npmjs.org/@rei-standard/amsg-shared/-/amsg-shared-0.1.0-next.0.tgz", + "integrity": "sha512-zgGGGWUh+h8zcSOYghh7vBrMQCQgSbgl0TeShGDH/LT8yWlViRrrFTJHCaVsmqtRu6v9nDHedMDavqv2NXz8JQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "packages/rei-standard-amsg/shared": { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.0", + "version": "0.1.0-next.2", "license": "MIT", "devDependencies": { "tsup": "^8.0.0", @@ -1935,6 +1953,15 @@ "engines": { "node": ">=20" } + }, + "packages/rei-standard-amsg/sw/node_modules/@rei-standard/amsg-shared": { + "version": "0.1.0-next.0", + "resolved": "https://registry.npmjs.org/@rei-standard/amsg-shared/-/amsg-shared-0.1.0-next.0.tgz", + "integrity": "sha512-zgGGGWUh+h8zcSOYghh7vBrMQCQgSbgl0TeShGDH/LT8yWlViRrrFTJHCaVsmqtRu6v9nDHedMDavqv2NXz8JQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } } } } From 87df60b9dd2ef7a6ec2c0dfdceca8357d2c9bdff Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 01:01:17 +0800 Subject: [PATCH 08/33] chore(amsg): release amsg-shared 0.1.0-next.3 / amsg-instant 0.8.0-next.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes targeting the same leaky-typedef root cause — untyped spread bypasses TS excess-property check, so silently-misused fields look correct to the IDE but no-op (or get ignored) at runtime. amsg-shared 0.1.0-next.3: - `NotificationDirective` typedef + `ContentPush.notification?` / `ToolRequestPush.notification?` — types the 7 fields amsg-sw `createNotificationFromPayload` actually consumes (`title` / `body` / `icon` / `badge` / `tag` / `renotify` / `requireInteraction`), closing the leaky-typedef path where callers had to spread untyped - `buildContentPush` / `buildToolRequestPush` 加 `notification?` 入参 — passthrough(同 `metadata`),shape-validate (plain object; string fields must be string; boolean fields must be boolean); unknown keys 透传保持 SW forward-compat - ToolRequestPush 上挂 `notification` 的理由:amsg-instant 切片时 demoted 前 N-1 段成 ContentPush,spread 整个 cleanPushObj,notification 跟着走;Reasoning / Error 不加(SW silent dispatch) - 7 字段全 typed(不只 title/body):只 typed 2 个会让另外 5 个仍走 untyped spread,recreate 同源 footgun amsg-instant 0.8.0-next.3: - fix: `pushPayload.splitPattern` per-push override。hook 在自己返回的 pushPayload 上写 `splitPattern: null` 不再被静默忽略——`splitHookPushPayload` 用 `pushPattern !== undefined` 检测在场(`undefined` 跟字段缺省等价,回退请求级;`null` / `[]` 显式关切)。字段名固定 `splitPattern`,不分 kind——push 的 messageKind 决定切谁的文本 - 校验沿用 validation.js 新 export 的 `validateSplitPattern`,形状错 / 正则不可编译 → 抛 `HookError` 信息形如 `pushPayload.splitPattern invalid: <原因>`(明确点位,避免跟请求级混;去重 validateSplitPattern 自带的 splitPattern 前缀) - Strip 是 clone-based:`const {splitPattern, ...rest} = pushObj` 生成新对象,原 pushObj 不动;hook 复用模板对象不会被库吃掉字段 - splitHookPushPayload 每个 push 跑一次,N-段切片 / ToolRequestPush prefix 降级都从已剥离 cleanPushObj spread,无二次切 测试: - shared 33/33(new: 8 个 notification arg 用例覆盖 ContentPush + ToolRequestPush 透传、undefined 不写 key、非 object 拒绝、字段类型校验、未知字段透传) - instant 175/175(new: 12 个 per-push override 用例覆盖 null/[] override、override > outer、reasoning/error 上 override 开切、undefined fallback、malformed 抛 HookError、ToolRequestPush demote+strip、no-match strip、clone-based 不 mutate、5 段非递归切) Coordinated: - shared 0.1.0-next.2 → 0.1.0-next.3 - instant 0.8.0-next.2 → 0.8.0-next.3(amsg-shared dep pin 同步) - package-lock.json 已 sync — 避免 next.2 那次的 `npm ci` 失败重演 - amsg-server / amsg-sw / amsg-client 不动 Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 6 +- .../rei-standard-amsg/instant/CHANGELOG.md | 24 ++ packages/rei-standard-amsg/instant/README.md | 24 ++ .../rei-standard-amsg/instant/package.json | 4 +- .../instant/src/message-processor.js | 97 ++++- .../instant/src/validation.js | 7 +- .../instant/test/split-pattern-hook.test.mjs | 392 ++++++++++++++++++ .../rei-standard-amsg/shared/CHANGELOG.md | 23 + .../rei-standard-amsg/shared/package.json | 2 +- .../rei-standard-amsg/shared/src/index.js | 83 ++++ .../shared/test/builders.test.mjs | 92 ++++ 11 files changed, 737 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index fee95fa..4890565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1874,10 +1874,10 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.2", + "version": "0.8.0-next.3", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.2" + "@rei-standard/amsg-shared": "0.1.0-next.3" }, "devDependencies": { "tsup": "^8.0.0", @@ -1929,7 +1929,7 @@ }, "packages/rei-standard-amsg/shared": { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.2", + "version": "0.1.0-next.3", "license": "MIT", "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 23cc5f2..07875d8 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.3 — `pushPayload.splitPattern` per-push override (pre-release) + +Coordinated with `@rei-standard/amsg-shared@0.1.0-next.3`. Install with `npm install @rei-standard/amsg-instant@next`. + +next.2 把 `splitPattern` 定位成纯请求级配置——hook 在自己返回的 `pushPayload` 上写 `splitPattern: null` 会被静默忽略(库不报错、不警告、TS 也不挡,因为 `ContentPush` 等 typedef 没有声明这个字段,spread 加任意 key 就会绕过 excess-property check)。这是 leaky API:用错位置看起来正常通过,但行为完全没生效。next.3 把这个口子收紧。 + +### Fixed + +- **`pushPayload.splitPattern` 现在被识别为 per-push override**。hook 返回的 `pushPayload` 自身带 `splitPattern` 字段时,对这一个 push 优先级高于请求级的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern`。字段名永远是 `splitPattern`(不分 kind,因为 push 的 `messageKind` 已经定了切谁的文本)。`null` / `[]` 关切;string / string[] 走 cascade。 +- **`undefined` 跟 `null` 严格区分**:`splitPattern: undefined`(或字段缺省)= 「没意见,回退请求级」;`splitPattern: null` / `[]` = 「这一个 push 显式关切,盖住请求级」。这跟请求级字段的语义、跟 JS 对 `undefined` 的直觉、跟 next.2 之前的请求级行为都保持一致——`undefined` 不会被错读成「override 在场但 disable」。 +- **Override 校验沿用 `validateSplitPattern`**——形状错(非 string/array、超 200 字符、超 10 项)或正则不可编译(`new RegExp(...)` throws)→ 抛 `HookError`,message 形如 `pushPayload.splitPattern invalid: <原因>`,明确点位(不会跟请求级混)。validateSplitPattern 原本带的 `splitPattern` / `splitPattern[i]` 前缀会被 strip,避免 `pushPayload.splitPattern invalid: splitPattern 不是...` 这种重复读起来含糊。 +- **Wire 不带 `splitPattern`**——库在交付前从所有 chunks(含 N-段切片、单段透传、ToolRequestPush 的 prefix 降级段)上 strip 掉这个字段,SW 永远收不到。`splitHookPushPayload` 每个 push 跑一次,降级 chunks 从已剥离的 parent spread,**不会发生二次切**。 + +### Unchanged + +- 请求级 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 语义和优先级**完全不变**——只是新增了 per-push 覆盖通道。 +- 没在 `pushPayload` 上写 `splitPattern` 的 hook 行为跟 next.2 byte-for-byte 一致(auto-emit reasoning、framework 内置的 `LOOP_EXCEEDED` ErrorPush 等都没有这字段,全部回退到请求级)。 +- 公共 API(hook 契约、handler options、HTTP wire format)零变化。 + +### Coordinated + +- 跟 `@rei-standard/amsg-shared@0.1.0-next.3` 一起发——shared 这版顺手补齐 `notification` 字段在 `ContentPush` / `ToolRequestPush` typedef 上的 7 字段 typed support + `buildContentPush` / `buildToolRequestPush` 的 `notification?` 入参(解决跟本 next.3 同源的 leaky-API:SW 早就消费 `notification.{title,body,icon,badge,tag,renotify,requireInteraction}`,但 typedef 没声明导致 caller 只能 untyped spread)。详见 shared CHANGELOG `0.1.0-next.3`。 +- `amsg-server` / `amsg-sw` / `amsg-client` 不动。SW 行为未变,只是 shared 把它已经支持的字段类型化了。 + ## 0.8.0-next.2 — splitPattern hook-mode 修复 + reasoning 两层切分 (pre-release) Coordinated with `@rei-standard/amsg-shared@0.1.0-next.2`. Install with `npm install @rei-standard/amsg-instant@next`. diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 2465b7c..c45e3d4 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -220,6 +220,30 @@ LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送(每 - `splitPattern`:`undefined` = 用默认正则;`null` / `[]` = 关闭切分。 - `reasoningSplitPattern` / `errorSplitPattern`:`undefined` = 不切(保守默认);`null` / `[]` 也是不切(显式关闭,效果一样)。这俩 kind 默认 off,是因为它们历史上就没切片 UX,引入 default-on 会改老 caller 行为。 +##### Per-push override:`pushPayload.splitPattern`(0.8.0-next.3+) + +在 hook 模式下,`onLLMOutput` 返回的 `pushPayload` 自身可以带一个 `splitPattern` 字段,作用域只限**这一个 push**。它优先于请求级的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern`,规则比请求级简单一些: + +- **字段名永远是 `splitPattern`**,不分 kind——因为 push 自己的 `messageKind` 已经定了。`reasoning` push 想切片,写 `pushPayload.splitPattern: '(...)'` 即可(无需 `reasoningSplitPattern`)。 +- **优先级 / 语义区分 `undefined` vs `null`**: + - 写 `splitPattern: null`(或 `[]`)= **显式关切**(这一个 push 不切,请求级被盖住)。 + - 写 `splitPattern: '(...)'` / `splitPattern: ['(\\n+)', '(...)']` = **显式开切**(用这套正则切,请求级被盖住)。 + - `splitPattern: undefined` 或字段缺省 = **没意见**,回退到请求级 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern`。 +- **校验**:与请求级共享 `validateSplitPattern`——形状或正则非法 → 抛 `HookError`,message 形如 `pushPayload.splitPattern invalid: <原因>`,明确点位是 push 上的字段(不会跟请求级混)。 +- **wire 不带这个字段**:库会在交付前把它从 chunks 里 strip 掉,SW 永远收不到 `splitPattern`。strip 一次性完成——`splitHookPushPayload` 每个 push 跑一次,N-段切片 / ToolRequestPush 的 prefix 降级段都从已剥离的 parent spread,不会被二次切。 + +```js +onLLMOutput: async (ctx) => ({ + decision: 'finish', + pushPayload: { + ...buildContentPush({ /* ... */ }), + splitPattern: null, // 这一段不切——即使请求级的 splitPattern 是开着的 + }, +}); +``` + +什么时候用:hook 想让某一类 push(比如「短促回复」「错误提示」)整段送出,而其他 push 仍按请求级配置切分。如果你想全局关闭,仍然直接在请求 body 上传 `splitPattern: null` 更省事。 + #### `apiUrl` 规范化(0.4.0+) 为了让用户不必死记 OpenAI 路径全名,Worker 会按下表规则补全 `apiUrl`。规则幂等:跑两次 = 跑一次,所以传完整 URL 也不会被改坏。 diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 1f722ba..373c6ee 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.2", + "version": "0.8.0-next.3", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", @@ -84,7 +84,7 @@ "node": ">=18" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.2" + "@rei-standard/amsg-shared": "0.1.0-next.3" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 404a721..3b97588 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -26,6 +26,7 @@ import { sendWebPush } from './webpush.js'; import { randomUUID } from './utils.js'; import { HookError, LlmCallError, PayloadTooLargeError } from './errors.js'; import { buildSessionContext, extractAssistantMessage } from './session-context.js'; +import { validateSplitPattern } from './validation.js'; const SLEEP_BETWEEN_MESSAGES_MS = 1500; // Sub-chunk spacing within a single Layer-1 segment. Byte-chunking is @@ -121,20 +122,48 @@ export function splitMessageIntoSentences(messageContent, splitPattern = null) { * `error` it means "do not split" (the kinds that didn't have a UX * for splitting historically). * + * Per-push override (0.8.0-next.3+): when the hook returns a + * `pushPayload` that owns a `splitPattern` field, the resolved + * `pushPattern` argument carries that value and takes precedence over + * the kind-specific request field above. The override is kind- + * agnostic (just `splitPattern` — the kind is already pinned by + * `pushPayload.messageKind`), so disable semantics collapse to the + * shared `null` / `[]` rule and never fall back to default-on. Callers + * pass `pushOverridePresent = false` when the field is absent so the + * request-level fallback kicks in. + * * @param {Record} payload * @param {unknown} kind + * @param {unknown} pushPattern - The hook-returned override (only consulted when `pushOverridePresent`). + * @param {boolean} pushOverridePresent - True iff `pushPayload` owned `splitPattern` (even when `null`). * @returns {{ textField: 'message' | 'reasoningContent', pattern: unknown, disabled: boolean } | null} - * `null` when the kind is not splittable (`'error'` with no opt-in - * pattern, unknown / free-form kinds). + * `null` when the kind is not splittable (unknown / free-form kinds + * with no override). An override on a free-form kind still applies + * when there's a usable text field — see `splitHookPushPayload`. */ -function pickSplitConfig(payload, kind) { +function pickSplitConfig(payload, kind, pushPattern, pushOverridePresent) { + // Resolve text-field by kind. Per-push override flips disable + // semantics to "explicit null/[] = off; anything else = use it". + const resolveDisabled = (pattern) => + pattern === null || (Array.isArray(pattern) && pattern.length === 0); + if (kind === 'content' || kind === 'tool_request') { + if (pushOverridePresent) { + return { textField: 'message', pattern: pushPattern, disabled: resolveDisabled(pushPattern) }; + } const pattern = payload.splitPattern; const disabled = pattern === null || (Array.isArray(pattern) && pattern.length === 0); return { textField: 'message', pattern, disabled }; } if (kind === 'reasoning') { + if (pushOverridePresent) { + // Per-push override skips the request-level "default-off" + // asymmetry: if the hook went out of its way to set the field + // on the push, treat any non-null/non-[] value as "split with + // this pattern" — same rule as content/tool_request override. + return { textField: 'reasoningContent', pattern: pushPattern, disabled: resolveDisabled(pushPattern) }; + } const pattern = payload.reasoningSplitPattern; // Default-off: undefined / null / [] all mean "do not split". const disabled = pattern === undefined @@ -143,6 +172,9 @@ function pickSplitConfig(payload, kind) { return { textField: 'reasoningContent', pattern, disabled }; } if (kind === 'error') { + if (pushOverridePresent) { + return { textField: 'message', pattern: pushPattern, disabled: resolveDisabled(pushPattern) }; + } const pattern = payload.errorSplitPattern; // Default-off, same as reasoning. const disabled = pattern === undefined @@ -175,6 +207,15 @@ function pickSplitConfig(payload, kind) { * - anything else → passthrough; the framework can't guess which * field of a free-form hook payload to split. * + * Per-push override (0.8.0-next.3+): when the hook-returned + * `pushPayload` owns a `splitPattern` field, it takes precedence over + * the kind-specific request field — including disabling the default + * split with `splitPattern: null` on a `content` push. The field is + * shape-validated (same caps as request-level via + * `validateSplitPattern`); malformed override throws `HookError`. The + * directive is stripped before delivery so it never appears on the + * wire, regardless of whether the split actually fired. + * * The original payload's `toolCalls`, `metadata`, and all push * metadata fields (`messageType` / `source` / `sessionId` / `timestamp` * / `messageKind` / `messageSubtype` / `taskId`) are preserved @@ -195,14 +236,50 @@ function splitHookPushPayload(pushPayload, payload) { const pushObj = /** @type {Record} */ (pushPayload); const kind = pushObj.messageKind; - const cfg = pickSplitConfig(payload || {}, kind); - if (!cfg || cfg.disabled) return [pushPayload]; + // Extract + validate the per-push override (if any) and produce a + // clean copy of pushObj that never carries `splitPattern` downstream + // — both single-chunk passthrough returns and N-chunk maps below + // use `cleanPushObj` so the directive can't leak onto the wire. + // + // `undefined` is treated as **absent** (same convention as + // request-level fields and as plain JS "value not really set"), so + // `pushPayload.splitPattern: undefined` falls back to the request- + // level field rather than being interpreted as a degenerate + // override. Only a non-`undefined` value (including `null` / `[]`) + // counts as an override. `JSON.stringify` already drops `undefined` + // properties at the wire layer, so the `undefined` case needs no + // explicit strip. + const pushPattern = pushObj.splitPattern; + const pushOverridePresent = pushPattern !== undefined; + let cleanPushObj = pushObj; + if (pushOverridePresent) { + const validationErr = validateSplitPattern(pushPattern); + if (validationErr) { + // Same severity as other pushPayload-shape contract violations + // (see `sendPushWithMaybeBlob`): surface as HookError so the + // caller's hook author sees a loud failure instead of a silent + // unsplit push. `validateSplitPattern` labels its errors with + // the literal "splitPattern" prefix (shared with the request- + // level validator) — strip it so the HookError doesn't read + // "pushPayload.splitPattern invalid: splitPattern ...". + const cleanedErr = validationErr.replace( + /^splitPattern(\[\d+\])?\s*/, + (_m, idx) => idx ? `${idx} ` : '', + ); + throw new HookError(`pushPayload.splitPattern invalid: ${cleanedErr}`); + } + const { splitPattern: _strip, ...rest } = pushObj; + cleanPushObj = rest; + } + + const cfg = pickSplitConfig(payload || {}, kind, pushPattern, pushOverridePresent); + if (!cfg || cfg.disabled) return [cleanPushObj]; - const text = pushObj[cfg.textField]; - if (typeof text !== 'string' || text.length === 0) return [pushPayload]; + const text = cleanPushObj[cfg.textField]; + if (typeof text !== 'string' || text.length === 0) return [cleanPushObj]; const segments = splitMessageIntoSentences(text, cfg.pattern); - if (segments.length <= 1) return [pushPayload]; + if (segments.length <= 1) return [cleanPushObj]; const total = segments.length; return segments.map((segment, i) => { @@ -213,7 +290,7 @@ function splitHookPushPayload(pushPayload, payload) { // Demote prefix chunks to ContentPush — drop `toolCalls` so the // client UI doesn't try to execute the tool N times. The last // chunk (below) keeps the original kind + toolCalls intact. - const { toolCalls: _drop, ...rest } = pushObj; + const { toolCalls: _drop, ...rest } = cleanPushObj; return { ...rest, messageKind: 'content', @@ -225,7 +302,7 @@ function splitHookPushPayload(pushPayload, payload) { } return { - ...pushObj, + ...cleanPushObj, messageId: chunkMessageId, [cfg.textField]: segment, messageIndex: i + 1, diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index 8e3ed23..abffa82 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -55,12 +55,17 @@ const SPLIT_PATTERN_MAX_ITEMS = 10; * `validateSplitPattern` (kept in lockstep). Accepts string, string[], or * absent/null. Returns an error message string, or null when valid. * + * Used for both request-level fields (`splitPattern` / + * `reasoningSplitPattern` / `errorSplitPattern`) and the per-push + * override `pushPayload.splitPattern` returned by hook authors + * (0.8.0-next.3+). Same shape rules, same caps. + * * The size caps are an input-size guard, NOT a ReDoS defense — a 6-char * pattern like `(a+)+$` is enough to trigger catastrophic backtracking. * Worker / runtime CPU limits are the real backstop; the blast radius is * self-inflicted only (caller's regex on caller's own LLM output). */ -function validateSplitPattern(value) { +export function validateSplitPattern(value) { if (value === undefined || value === null) return null; const isArray = Array.isArray(value); const items = isArray ? value : [value]; diff --git a/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs b/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs index e7491e6..93cba23 100644 --- a/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs +++ b/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs @@ -764,6 +764,398 @@ describe('hook mode + splitPattern — serial ordering', () => { }); }); +// ─── 10b) per-push splitPattern override on pushPayload (next.3+) ─────── +// +// 0.8.0-next.2 treated `splitPattern` strictly as a request-level field +// — a hook that wrote `splitPattern: null` on its own pushPayload had +// the field silently ignored (the only way to disable splitting for one +// push was to flip the outer request body). next.3 promotes +// `pushPayload.splitPattern` to a per-push override that takes +// precedence over the request-level field for that one push, and gets +// stripped before delivery so it never leaks onto the wire. + +describe('hook mode + splitPattern — per-push override', () => { + it('pushPayload.splitPattern: null disables split even when outer request is default-on', async () => { + const { pushes, sleeps } = await runProcessor( + basePayload(), // outer request: splitPattern undefined → default sentence-split on + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'override-null', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: null, + }, + }), + } + ); + assert.equal(pushes.length, 1, 'override null → single push'); + assert.equal(pushes[0].message, 'A。B。C。'); + assert.equal(pushes[0].messageId, 'override-null'); + assert.deepEqual(sleeps, []); + // Stripped before delivery — never appears on the wire. + assert.equal('splitPattern' in pushes[0], false); + }); + + it('pushPayload.splitPattern beats outer request splitPattern (override > request)', async () => { + // Outer says split-by-newline, push override says split-by-sentence. + // Override should win → 3 chunks on `。`, not 1. + const { pushes } = await runProcessor( + basePayload({ splitPattern: '(\\n+)' }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'override-string', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: '([。!?!?]+)', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); + // Stripped from every chunk, not just one. + for (const p of pushes) assert.equal('splitPattern' in p, false); + }); + + it('pushPayload.splitPattern: [] disables (same `null`-or-empty rule as request-level)', async () => { + const { pushes } = await runProcessor( + basePayload({ splitPattern: '([。!?!?]+)' }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'override-empty', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: [], + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'A。B。C。'); + assert.equal('splitPattern' in pushes[0], false); + }); + + it('pushPayload.splitPattern enables split on a default-off kind (reasoning)', async () => { + // Reasoning is default-off at request level. Hook puts splitPattern + // on the ReasoningPush → that one push splits even though the + // request omitted `reasoningSplitPattern`. + const { pushes, sleeps } = await runProcessor( + basePayload(), + { + autoEmitReasoning: false, + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'reasoning', + messageType: 'instant', + source: 'instant', + messageId: 'reason-override', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + reasoningContent: 'first。second。third。', + splitPattern: '([。!?!?]+)', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.messageKind), ['reasoning', 'reasoning', 'reasoning']); + assert.deepEqual(pushes.map((p) => p.reasoningContent), ['first。', 'second。', 'third。']); + assert.deepEqual(sleeps, [1500, 1500]); + for (const p of pushes) assert.equal('splitPattern' in p, false); + }); + + it('pushPayload.splitPattern enables split on a default-off kind (error)', async () => { + const { pushes } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'error', + messageType: 'instant', + source: 'instant', + messageId: 'err-override', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + code: 'CUSTOM_FAIL', + message: 'first。second。third。', + splitPattern: '([。!?!?]+)', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.messageKind), ['error', 'error', 'error']); + assert.deepEqual(pushes.map((p) => p.code), ['CUSTOM_FAIL', 'CUSTOM_FAIL', 'CUSTOM_FAIL']); + for (const p of pushes) assert.equal('splitPattern' in p, false); + }); + + it('absent pushPayload.splitPattern falls through to outer request (existing behaviour)', async () => { + // No `splitPattern` field on the push → outer request controls. + const { pushes } = await runProcessor( + basePayload({ splitPattern: null }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'no-override', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + }, + }), + } + ); + assert.equal(pushes.length, 1, 'outer null disables → single push'); + assert.equal(pushes[0].message, 'A。B。C。'); + }); + + it('malformed pushPayload.splitPattern surfaces as HookError', async () => { + // Unbalanced regex group — same shape rule as request-level. + await assert.rejects( + runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'bad-override', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: '(', + }, + }), + } + ), + (err) => { + assert.equal(err.name, 'HookError'); + assert.ok(/pushPayload\.splitPattern invalid/.test(err.message)); + return true; + } + ); + }); + + it('ToolRequestPush override demotes prefix chunks + binds toolCalls to last (same as request-level)', async () => { + const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; + const { pushes } = await runProcessor( + basePayload({ splitPattern: null }), // outer says off — override re-enables for this push only + { + onLLMOutput: (sctx) => ({ + decision: 'tool-request', + pushPayload: { + messageKind: 'tool_request', + messageType: 'instant', + source: 'instant', + messageId: 'tool-override', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'one。two。three。', + toolCalls, + splitPattern: '([。!?!?]+)', + }, + }), + } + ); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map((p) => p.messageKind), ['content', 'content', 'tool_request']); + assert.equal('toolCalls' in pushes[0], false); + assert.equal('toolCalls' in pushes[1], false); + assert.deepEqual(pushes[2].toolCalls, toolCalls); + // Override field is stripped from every chunk, including the + // demoted ContentPush chunks (which spread from cleanPushObj). + for (const p of pushes) assert.equal('splitPattern' in p, false); + }); + + it('splitPattern stripped even when override fires but produces a single segment', async () => { + // Punctuation-free message + a regex that won't match → single + // chunk passthrough. The strip should still apply. + const { pushes } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'no-match-strip', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'no punctuation here', + splitPattern: '([。!?!?]+)', + }, + }), + } + ); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].messageId, 'no-match-strip', 'no-match passthrough still preserves messageId'); + assert.equal('splitPattern' in pushes[0], false, 'no-match passthrough still strips override'); + }); + + // ─── undefined vs null distinction ────────────────────────────────── + // + // `null` is an *opinion* ("explicitly off for this push, ignore the + // request-level field"). `undefined` is *not an opinion* ("I didn't + // set this, do whatever the request-level field says"). Matches the + // request-level convention and plain-JS reading of `undefined`. + + it('splitPattern: undefined is treated as absent — falls back to outer request', async () => { + // Outer says default-on. Push has the field set to `undefined`. + // Should behave the same as if the field were absent: split. + const { pushes } = await runProcessor( + basePayload(), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'undef-fallback', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: undefined, + }, + }), + } + ); + assert.equal(pushes.length, 3, 'undefined override must NOT shadow outer default-on'); + assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); + for (const p of pushes) assert.equal('splitPattern' in p, false); + }); + + it('splitPattern: undefined + outer null → respects outer null (still falls back)', async () => { + const { pushes } = await runProcessor( + basePayload({ splitPattern: null }), + { + onLLMOutput: (sctx) => ({ + decision: 'finish', + pushPayload: { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'undef-outer-null', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: undefined, + }, + }), + } + ); + assert.equal(pushes.length, 1, 'undefined must fall back to outer null → unsplit'); + assert.equal(pushes[0].message, 'A。B。C。'); + }); + + // ─── non-recursive split: demoted chunks aren't re-split ──────────── + // + // `splitHookPushPayload` runs exactly once per push delivery. The + // ToolRequestPush prefix-demotion path produces ContentPush chunks + // that share the same `cleanPushObj` (already-stripped) parent — so + // there's no second pass that could see the override and re-split. + // This test wires up a scenario that would catch any future + // recursion: a 5-segment override that, if applied twice, would + // shatter into 25+ chunks. Asserting exact count == 5 pins the + // single-pass invariant. + + it('strip is clone-based — does NOT mutate the original pushPayload', async () => { + // Hook authors may legitimately return a cached / shared + // pushPayload template (e.g. a frozen base + per-iteration + // overrides). If the library used `delete` on the original + // object, the second reuse of the same reference would silently + // lose its `splitPattern`. Assert clone-based strip by inspecting + // the original object after delivery. + const shared = { + messageKind: 'content', + messageType: 'instant', + source: 'instant', + messageId: 'shared-template', + sessionId: 'sess-clone', + timestamp: '2026-01-01T00:00:00.000Z', + message: 'A。B。C。', + splitPattern: null, // explicit-off + }; + const { pushes } = await runProcessor( + basePayload(), + { + onLLMOutput: () => ({ decision: 'finish', pushPayload: shared }), + } + ); + assert.equal(pushes.length, 1, 'override null disables → single push'); + // Wire-clean. + for (const p of pushes) assert.equal('splitPattern' in p, false); + // Original object untouched — hook author's template is safe to + // reuse on the next iteration / next request. + assert.equal('splitPattern' in shared, true, 'original pushPayload must NOT be mutated'); + assert.equal(shared.splitPattern, null, 'original splitPattern value preserved'); + assert.equal(shared.messageId, 'shared-template', 'other fields untouched'); + }); + + it('override on ToolRequestPush splits once — demoted ContentPush chunks not re-split', async () => { + const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; + const { pushes } = await runProcessor( + basePayload({ splitPattern: null }), // outer off, override on + { + onLLMOutput: (sctx) => ({ + decision: 'tool-request', + pushPayload: { + messageKind: 'tool_request', + messageType: 'instant', + source: 'instant', + messageId: 'recursion-check', + sessionId: sctx.sessionId, + timestamp: '2026-01-01T00:00:00.000Z', + message: 'a。b。c。d。e。', // 5 sentences → 5 chunks if single-pass + toolCalls, + splitPattern: '([。!?!?]+)', + }, + }), + } + ); + assert.equal(pushes.length, 5, 'must be exactly 5 — recursion would inflate this'); + assert.deepEqual(pushes.map((p) => p.messageKind), [ + 'content', 'content', 'content', 'content', 'tool_request', + ]); + assert.deepEqual(pushes.map((p) => p.message), ['a。', 'b。', 'c。', 'd。', 'e。']); + // Demoted ContentPush prefix chunks (chunks 0..3) carry no + // toolCalls and no splitPattern. Final tool_request chunk still + // has toolCalls but no splitPattern. + for (let i = 0; i < 4; i++) { + assert.equal('toolCalls' in pushes[i], false, `chunk ${i}: no toolCalls`); + assert.equal('splitPattern' in pushes[i], false, `chunk ${i}: no splitPattern`); + } + assert.deepEqual(pushes[4].toolCalls, toolCalls); + assert.equal('splitPattern' in pushes[4], false, 'final chunk: no splitPattern'); + }); +}); + // ─── 11) no startup warn about splitPattern + onLLMOutput combo ───────── describe('createInstantHandler — no warn about splitPattern in hook mode', () => { diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md index db8ce95..86ab993 100644 --- a/packages/rei-standard-amsg/shared/CHANGELOG.md +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -1,5 +1,28 @@ # @rei-standard/amsg-shared +## 0.1.0-next.3 — `notification` 字段 typed support (pre-release) + +Coordinated with `@rei-standard/amsg-instant@0.8.0-next.3`. Install with `npm install @rei-standard/amsg-shared@next`. Wire format unchanged — additive typedef + new optional builder arg. + +`notification` 字段一直被 `amsg-sw` 的 `createNotificationFromPayload` 当作 `showNotification` 渲染指令读取(`title` / `body` / `icon` / `badge` / `tag` / `renotify` / `requireInteraction` 共 7 字段),但 `ContentPush` / `ToolRequestPush` typedef 没声明,hook 作者只能 untyped spread——跟 next.3 amsg-instant 修掉的 `pushPayload.splitPattern` 是同一种 leaky-API。这版补上类型,IDE 给完整的 7 字段补全。 + +### New + +- **`NotificationDirective` typedef** — 显式 7 个 optional 字段(`title` / `body` / `icon` / `badge` / `tag` / `renotify` / `requireInteraction`),跟 `amsg-sw` `createNotificationFromPayload` 实际消费的字段一一对应。typedef 写了 SW 端的 fallback 链(`notification.title` → `payload.title` → `来自 {contactName}` → `'New notification'`),producer 不用再翻 SW 源码。 +- **`ContentPush.notification?` + `ToolRequestPush.notification?`** — 两个 push kind 加可选字段。`ToolRequestPush` 上也挂是为了让 amsg-instant 的 sentence-splitter demote 出来的前 N-1 个 ContentPush chunks 继承(demoted 时 spread 整个 cleanPushObj,所以 notification 跟着走)。`ReasoningPush` / `ErrorPush` 不加——SW 这俩 kind 是 silent dispatch,挂上也不会触发渲染。 +- **`buildContentPush` / `buildToolRequestPush` 加 `notification?` 入参** — passthrough 不深拷贝(跟 `metadata` 一致的处理)。形状校验:必须是 plain object,`title` / `body` / `icon` / `badge` / `tag` 是 string、`renotify` / `requireInteraction` 是 boolean。未知字段透传(保 SW forward-compat)。 + +### 为什么 typed 全部 7 个字段(而不只是 `title` / `body`) + +最早的 follow-up 草稿写的是 `{ title?: string; body?: string }`。但 SW 实际读 7 个,只 typed 2 个会让另外 5 个仍然走 untyped spread——recreate next.3 amsg-instant 那个 footgun 的根本原因(typedef 不全 → spread bypass → 行为静默)。一次性 typed 完整集干净。SullyOS 这种 caller 后续 1 行 diff 就能从 untyped spread 迁到 typed arg。 + +### 行为兼容 + +- 不传 `notification`:wire format 跟 next.2 byte-for-byte 一致(builder 出口不写这个 key)。 +- 老 amsg-sw / amsg-instant 等 caller 不受影响——typedef 是 additive,builder 没改原有签名。 +- Wire schema 不动;`AmsgPush` 联合类型不动;type guards 不变。 +- 跟 amsg-instant 0.8.0-next.3 的 `pushPayload.splitPattern` per-push override 协调发版。 + ## 0.1.0-next.2 — ReasoningPush 字节切分 + multi-part 索引字段 (pre-release) Coordinated with `@rei-standard/amsg-instant@0.8.0-next.2`. Install with `npm install @rei-standard/amsg-shared@next`. Existing single-shot ReasoningPush callers are wire-compatible — the new fields are emitted only when chunking actually fires. diff --git a/packages/rei-standard-amsg/shared/package.json b/packages/rei-standard-amsg/shared/package.json index 9fdbf3e..2542b5d 100644 --- a/packages/rei-standard-amsg/shared/package.json +++ b/packages/rei-standard-amsg/shared/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.2", + "version": "0.1.0-next.3", "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/shared/src/index.js b/packages/rei-standard-amsg/shared/src/index.js index 1efac43..bde8871 100644 --- a/packages/rei-standard-amsg/shared/src/index.js +++ b/packages/rei-standard-amsg/shared/src/index.js @@ -99,6 +99,38 @@ export const PUSH_SOURCE = Object.freeze({ // ─── Per-kind interfaces ──────────────────────────────────────────────── +/** + * SW-rendering directive carried on `ContentPush` / `ToolRequestPush`. + * Mirrors the seven fields that `amsg-sw`'s `createNotificationFromPayload` + * actually consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction}`) + * — typing all seven (rather than just `title` / `body`) so callers + * don't lose IDE checking on the other five and slip back into the + * untyped-spread footgun this typedef was added to close. + * + * Routing in SW (kept here so producers don't have to cross-check): + * - `messageKind: 'content'` (and legacy un-kinded payloads) → + * `notification.*` is consulted, with per-field fallback to + * the top-level `title` / `avatarUrl` / `messageId` and finally + * to the SW's `defaultIcon` / `defaultBadge` options. Everything + * else (`tag`, `renotify`, `requireInteraction`) has no top-level + * fallback — set them under `notification` or accept the SW + * default (`messageId`-derived tag, no renotify, no requireInteraction). + * - `messageKind: 'reasoning'` / `'tool_request'` / `'error'` → + * dispatched silently to controlled clients. `notification` is + * ignored. (It's still typed on `ToolRequestPush` because the + * splitter demotes prefix chunks to `messageKind: 'content'`, at + * which point the field starts mattering.) + * + * @typedef {Object} NotificationDirective + * @property {string} [title] - Notification title override. + * @property {string} [body] - Notification body override. + * @property {string} [icon] - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`). + * @property {string} [badge] - Badge URL override (falls back to SW `defaultBadge`). + * @property {string} [tag] - Notification grouping tag; matching tag replaces the prior notification. + * @property {boolean} [renotify] - When tag matches, still vibrate/sound. Default false at SW. + * @property {boolean} [requireInteraction] - Notification stays until user dismisses. Default false at SW. + */ + /** * Final user-facing content. Sentence-split bursts of N use * `messageIndex` (1-based) + `totalMessages` so the client can @@ -113,6 +145,7 @@ export const PUSH_SOURCE = Object.freeze({ * messageIndex?: number, * totalMessages?: number, * taskId?: string | null, + * notification?: NotificationDirective, * }} ContentPush */ @@ -168,6 +201,7 @@ export const PUSH_SOURCE = Object.freeze({ * title?: string, * contactName?: string, * message?: string, + * notification?: NotificationDirective, * }} ToolRequestPush */ @@ -238,6 +272,12 @@ function requireField(kind, field, value) { * @param {number} [args.totalMessages] * @param {string | null} [args.taskId] * @param {Object} [args.metadata] + * @param {NotificationDirective} [args.notification] + * - SW-side `showNotification` overrides for content + * (and for ToolRequestPush prefix chunks that get + * demoted to `content` during sentence-split). All + * fields optional; see {@link NotificationDirective} + * for the SW fallback chain. * @returns {ContentPush} */ export function buildContentPush(args) { @@ -248,6 +288,7 @@ export function buildContentPush(args) { if (typeof args.message !== 'string') { throw new Error("[amsg-shared] ContentPush: 'message' must be a string"); } + validateNotificationArg('ContentPush', args.notification); /** @type {ContentPush} */ const push = { @@ -267,6 +308,7 @@ export function buildContentPush(args) { if (args.totalMessages !== undefined) push.totalMessages = args.totalMessages; if (args.taskId !== undefined) push.taskId = args.taskId; if (args.metadata !== undefined) push.metadata = args.metadata; + if (args.notification !== undefined) push.notification = args.notification; return push; } @@ -352,6 +394,15 @@ export function buildReasoningPush(args) { * @param {string} [args.message] * @param {string} [args.messageSubtype] * @param {Object} [args.metadata] + * @param {NotificationDirective} [args.notification] + * - SW notification overrides. Used after the + * splitter demotes prefix chunks to `content` + * (where `messageKind: 'content'` triggers + * `showNotification`). On the un-demoted last + * chunk (`messageKind: 'tool_request'`) the + * SW dispatches silently and the field is + * ignored — typed here purely so the demoted + * chunks inherit it via the splitter's spread. * @returns {ToolRequestPush} */ export function buildToolRequestPush(args) { @@ -362,6 +413,7 @@ export function buildToolRequestPush(args) { if (!Array.isArray(args.toolCalls) || args.toolCalls.length === 0) { throw new Error("[amsg-shared] ToolRequestPush: 'toolCalls' must be a non-empty array"); } + validateNotificationArg('ToolRequestPush', args.notification); /** @type {ToolRequestPush} */ const push = { @@ -378,9 +430,40 @@ export function buildToolRequestPush(args) { if (args.message !== undefined) push.message = args.message; if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; if (args.metadata !== undefined) push.metadata = args.metadata; + if (args.notification !== undefined) push.notification = args.notification; return push; } +/** + * Validate the optional `notification` argument on + * `buildContentPush` / `buildToolRequestPush`. Plain object required + * (`null` / arrays / primitives rejected); field-level shape is + * checked best-effort — `title` / `body` / `icon` / `badge` / `tag` + * must be strings when present, `renotify` / `requireInteraction` + * must be booleans. Unknown keys are tolerated so the SW's + * forward-compatibility (it just won't read them) is preserved. + * + * @param {string} kind + * @param {unknown} value + */ +function validateNotificationArg(kind, value) { + if (value === undefined) return; + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`[amsg-shared] ${kind}: 'notification' must be a plain object`); + } + const n = /** @type {Record} */ (value); + for (const f of ['title', 'body', 'icon', 'badge', 'tag']) { + if (n[f] !== undefined && typeof n[f] !== 'string') { + throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`); + } + } + for (const f of ['renotify', 'requireInteraction']) { + if (n[f] !== undefined && typeof n[f] !== 'boolean') { + throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`); + } + } +} + /** * Build an {@link ErrorPush}. Replaces the legacy * `{ type: 'error', code: '...' }` envelope. The new shape carries diff --git a/packages/rei-standard-amsg/shared/test/builders.test.mjs b/packages/rei-standard-amsg/shared/test/builders.test.mjs index 73a1153..844c265 100644 --- a/packages/rei-standard-amsg/shared/test/builders.test.mjs +++ b/packages/rei-standard-amsg/shared/test/builders.test.mjs @@ -125,6 +125,98 @@ test('buildReasoningPush rejects empty reasoningContent', () => { ); }); +// ─── notification typed support (next.3+) ───────────────────────────── +// +// `notification` was previously an untyped spread footgun — hook +// authors could write any of the seven fields amsg-sw reads +// (`title` / `body` / `icon` / `badge` / `tag` / `renotify` / +// `requireInteraction`), but the builder didn't accept it as a typed +// arg so spread-based usage bypassed the IDE. + +test('buildContentPush threads notification through verbatim', () => { + const notification = { + title: 'Custom', + body: 'Hello', + icon: 'https://cdn.example/icon.png', + badge: 'https://cdn.example/badge.png', + tag: 'thread-42', + renotify: true, + requireInteraction: false, + }; + const push = buildContentPush({ ...COMMON, message: 'hi', notification }); + assert.deepEqual(push.notification, notification); + // Reference passthrough — not deep-cloned, same as `metadata`. + assert.equal(push.notification, notification); +}); + +test('buildContentPush omits notification key when arg is undefined', () => { + const push = buildContentPush({ ...COMMON, message: 'hi' }); + assert.ok(!('notification' in push)); +}); + +test('buildContentPush rejects non-object notification', () => { + for (const bad of [null, 'string', 42, true, [1, 2]]) { + assert.throws( + () => buildContentPush({ ...COMMON, message: 'hi', notification: bad }), + /'notification' must be a plain object/, + `value ${JSON.stringify(bad)} should reject`, + ); + } +}); + +test('buildContentPush rejects non-string notification.{title,body,icon,badge,tag}', () => { + for (const field of ['title', 'body', 'icon', 'badge', 'tag']) { + assert.throws( + () => buildContentPush({ ...COMMON, message: 'hi', notification: { [field]: 42 } }), + new RegExp(`'notification\\.${field}' must be a string`), + `notification.${field}: 42 should reject`, + ); + } +}); + +test('buildContentPush rejects non-boolean notification.{renotify,requireInteraction}', () => { + for (const field of ['renotify', 'requireInteraction']) { + assert.throws( + () => buildContentPush({ ...COMMON, message: 'hi', notification: { [field]: 'yes' } }), + new RegExp(`'notification\\.${field}' must be a boolean`), + `notification.${field}: "yes" should reject`, + ); + } +}); + +test('buildContentPush tolerates unknown notification fields (SW forward-compat)', () => { + // The SW reads a known set of fields; anything else is ignored at + // its end. Builder shouldn't gatekeep beyond shape — typed args are + // a TS-side helper, not a wire validator. + const push = buildContentPush({ + ...COMMON, + message: 'hi', + notification: { title: 'ok', futureField: 'whatever' }, + }); + assert.equal(push.notification.futureField, 'whatever'); +}); + +test('buildToolRequestPush threads notification through (for demoted prefix chunks)', () => { + const notification = { title: 'pre-tool narration', tag: 'tool-call-42' }; + const push = buildToolRequestPush({ + ...COMMON, + toolCalls: [{ id: 'c1', type: 'function', function: { name: 'x' } }], + notification, + }); + assert.deepEqual(push.notification, notification); +}); + +test('buildToolRequestPush rejects malformed notification', () => { + assert.throws( + () => buildToolRequestPush({ + ...COMMON, + toolCalls: [{ id: 'c1', type: 'function', function: { name: 'x' } }], + notification: 'not-an-object', + }), + /'notification' must be a plain object/, + ); +}); + test('buildToolRequestPush requires a non-empty toolCalls array', () => { const push = buildToolRequestPush({ ...COMMON, From 695b61b033c3384eccdbeef4c8da9d48fc42a807 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 14:57:32 +0800 Subject: [PATCH 09/33] =?UTF-8?q?feat(amsg-instant)!:=200.8.0-next.4=20?= =?UTF-8?q?=E2=80=94=20reject=20removed=20split-pattern=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Request body fields `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` are now rejected with INVALID_PAYLOAD_FORMAT and a migration hint pointing at `decision.pushPayloads`. Removes `validatePerKindSplitPatterns` from validation and stops re-exporting `splitMessageIntoSentences` (legacy path still uses it internally; hook authors don't get it back). `validateSplitPattern` itself stays for now — `splitHookPushPayload` in message-processor.js still calls it on the per-push override path. That call site is rewired by Task 2/3 of this migration, at which point the helper goes too. handler.test.mjs picks up `splitMessageIntoSentences` directly from src/message-processor.js (instead of via the dropped re-export) so the file still loads. The legacy describe blocks that exercise the now- rejected fields stay in place and fail loudly until Task 6 deletes them. Breaking on purpose — next.4 is pre-release; we're consolidating two overlapping mechanisms (lib-side splitPattern auto-split + hook-side pushPayload) into one (pushPayloads array) before 1.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/src/index.js | 1 - .../instant/src/validation.js | 68 ++++++++----------- .../instant/test/handler.test.mjs | 26 ++++++- 3 files changed, 52 insertions(+), 43 deletions(-) diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 2f0011d..e0edceb 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -611,7 +611,6 @@ async function verifyBearerToken(request, signingKey, respond) { export { validateInstantPayload, validateAvatarUrl, validateContinuePayload } from './validation.js'; export { - splitMessageIntoSentences, processInstantMessage, normalizeAiApiUrl, sendPushWithMaybeBlob, diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index abffa82..a5a1253 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -55,15 +55,13 @@ const SPLIT_PATTERN_MAX_ITEMS = 10; * `validateSplitPattern` (kept in lockstep). Accepts string, string[], or * absent/null. Returns an error message string, or null when valid. * - * Used for both request-level fields (`splitPattern` / - * `reasoningSplitPattern` / `errorSplitPattern`) and the per-push - * override `pushPayload.splitPattern` returned by hook authors - * (0.8.0-next.3+). Same shape rules, same caps. - * - * The size caps are an input-size guard, NOT a ReDoS defense — a 6-char - * pattern like `(a+)+$` is enough to trigger catastrophic backtracking. - * Worker / runtime CPU limits are the real backstop; the blast radius is - * self-inflicted only (caller's regex on caller's own LLM output). + * As of next.4 the three request-body fields (`splitPattern` / + * `reasoningSplitPattern` / `errorSplitPattern`) are rejected outright + * in `validateInstantPayload` / `validateContinuePayload` — only the + * per-push override `pushPayload.splitPattern` returned by hook authors + * still reaches this validator (via `splitHookPushPayload` in + * `message-processor.js`). Task 2/3 of the next.4 migration removes + * that last call site; this helper goes with it. */ export function validateSplitPattern(value) { if (value === undefined || value === null) return null; @@ -313,8 +311,16 @@ export function validateInstantPayload(payload, opts) { }; } - const splitErr = validatePerKindSplitPatterns(payload); - if (splitErr) return splitErr; + const removedField = ['splitPattern', 'reasoningSplitPattern', 'errorSplitPattern'] + .find((field) => payload[field] !== undefined); + if (removedField) { + return { + valid: false, + errorCode: 'INVALID_PAYLOAD_FORMAT', + errorMessage: `${removedField} is removed in next.4; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, + details: { invalidFields: [removedField] }, + }; + } // Hook-path-only fields are validated regardless of which path // we're on — even legacy callers passing them should get a clean @@ -451,40 +457,20 @@ export function validateContinuePayload(payload, opts) { payload.avatarUrl = null; } - const splitErr = validatePerKindSplitPatterns(payload); - if (splitErr) return splitErr; + const removedField = ['splitPattern', 'reasoningSplitPattern', 'errorSplitPattern'] + .find((field) => payload[field] !== undefined); + if (removedField) { + return { + valid: false, + errorCode: 'INVALID_PAYLOAD_FORMAT', + errorMessage: `${removedField} is removed in next.4; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, + details: { invalidFields: [removedField] }, + }; + } return validateHookPathSharedFields(payload, opts) || { valid: true }; } -/** - * Validate the three per-kind split-pattern fields. All three follow - * the same shape rules (`validateSplitPattern`) — only their - * semantics differ at runtime (`content` / `tool_request` default-on, - * `reasoning` / `error` default-off). - * - * @param {Object} payload - */ -function validatePerKindSplitPatterns(payload) { - for (const field of ['splitPattern', 'reasoningSplitPattern', 'errorSplitPattern']) { - const err = validateSplitPattern(payload[field]); - if (err) { - // Re-label the error with the actual field name so the caller - // knows which knob to fix. `validateSplitPattern` itself uses - // the literal label "splitPattern" / "splitPattern[i]"; rewrite - // it for the per-kind fields. - const labelled = field === 'splitPattern' ? err : err.replace(/^splitPattern/, field); - return { - valid: false, - errorCode: 'INVALID_PAYLOAD_FORMAT', - errorMessage: labelled, - details: { invalidFields: [field] }, - }; - } - } - return null; -} - /** * Validate fields that are only meaningful on the hook path (or * `/continue`). Returns null when everything looks fine so the diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index 9d95a40..fcb529d 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -3,9 +3,14 @@ import assert from 'node:assert/strict'; import { createInstantHandler, - splitMessageIntoSentences, validateInstantPayload, } from '../src/index.js'; +// `splitMessageIntoSentences` lost its public re-export from src/index.js in +// next.4 (hook authors no longer get a split helper). The legacy test block +// below (`describe('splitMessageIntoSentences', ...)`) is slated for removal +// in Task 6 of the migration; meanwhile, pull the function from its internal +// home so this file still loads. +import { splitMessageIntoSentences } from '../src/message-processor.js'; import { generateTestVapid, generateTestSubscription, @@ -283,6 +288,25 @@ describe('validateInstantPayload', () => { }); }); +describe('next.4 — split-pattern fields removed', () => { + it('rejects request body splitPattern with INVALID_PAYLOAD_FORMAT', () => { + const r = validateInstantPayload(makeValidPayload({ splitPattern: '([。!?!?]+)' })); + assert.equal(r.valid, false); + assert.equal(r.errorCode, 'INVALID_PAYLOAD_FORMAT'); + assert.match(r.errorMessage, /splitPattern is removed in next\.4/); + }); + it('rejects request body reasoningSplitPattern', () => { + const r = validateInstantPayload(makeValidPayload({ reasoningSplitPattern: '([。!?!?]+)' })); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /reasoningSplitPattern is removed in next\.4/); + }); + it('rejects request body errorSplitPattern', () => { + const r = validateInstantPayload(makeValidPayload({ errorSplitPattern: '([。!?!?]+)' })); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /errorSplitPattern is removed in next\.4/); + }); +}); + // ─── Unit: sentence splitting ───────────────────────────────────────── describe('splitMessageIntoSentences', () => { From 85cfec2276987dd506576e7ea97e14b92965dcd9 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 15:05:38 +0800 Subject: [PATCH 10/33] =?UTF-8?q?feat(amsg-instant)!:=200.8.0-next.4=20?= =?UTF-8?q?=E2=80=94=20reject=20decision.pushPayload=20+=20pushPayloads:[]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertValidDecision now requires `pushPayloads: [...]` on `finish` / `tool-request` decisions. The singular `pushPayload` field is rejected with a migration line. Empty `pushPayloads: []` is rejected and points the caller at `decision: 'skip-push'`. Per-push `splitPattern` is also rejected. These cases all route through the existing HOOK_THREW pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/src/message-processor.js | 43 ++++++++++++-- .../instant/test/agentic-loop.test.mjs | 59 +++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 3b97588..5f426ca 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -1040,12 +1040,45 @@ function assertValidDecision(decision) { if (typeof tag !== 'string' || !VALID_DECISIONS.has(tag)) { throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyForError(tag)}`); } - if (tag === 'continue' && !Array.isArray(/** @type {{ nextHistory?: unknown }} */ (decision).nextHistory)) { - throw new TypeError('decision:"continue" requires a nextHistory array'); + + const hasSingular = Object.prototype.hasOwnProperty.call(decision, 'pushPayload'); + const hasPlural = Object.prototype.hasOwnProperty.call(decision, 'pushPayloads'); + + if (hasSingular) { + throw new TypeError( + hasPlural + ? 'pushPayload (singular) is removed in next.4, use pushPayloads' + : 'pushPayload (singular) is removed in next.4, use pushPayloads: [yourPayload]' + ); + } + + if (tag === 'continue') { + if (!Array.isArray(/** @type {{ nextHistory?: unknown }} */ (decision).nextHistory)) { + throw new TypeError('decision:"continue" requires a nextHistory array'); + } + return; } - if ((tag === 'finish' || tag === 'tool-request') - && /** @type {{ pushPayload?: unknown }} */ (decision).pushPayload === undefined) { - throw new TypeError(`decision:"${tag}" requires a pushPayload`); + + if (tag === 'skip-push') { + return; + } + + // 'finish' / 'tool-request' — both need pushPayloads array + if (!hasPlural || !Array.isArray(/** @type {{ pushPayloads?: unknown }} */ (decision).pushPayloads)) { + throw new TypeError(`decision:"${tag}" requires a pushPayloads array`); + } + const pushes = /** @type {Array} */ (decision.pushPayloads); + if (pushes.length === 0) { + throw new TypeError('pushPayloads: [] — use decision: skip-push to skip notification entirely'); + } + for (let i = 0; i < pushes.length; i++) { + const p = pushes[i]; + if (!p || typeof p !== 'object' || Array.isArray(p)) { + throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyForError(p)}`); + } + if (Object.prototype.hasOwnProperty.call(p, 'splitPattern')) { + throw new TypeError(`pushPayloads[${i}].splitPattern is removed in next.4; caller is responsible for splitting`); + } } } diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 08635c6..0821c9b 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -701,3 +701,62 @@ describe('validateInstantPayload hookPath flag', () => { assert.equal(r.valid, true); }); }); + +// ─── next.4 — decision contract: pushPayloads ────────────────────────── + +describe('next.4 — decision contract: pushPayloads', () => { + async function dispatchHookReturn(hookReturn) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('llm answer'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => hookReturn, + }); + const res = await handler(makeRequest('http://h/instant', basePayload())); + return { res, body: await res.json(), router }; + } + + it('rejects singular pushPayload field with HookError + migration message', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'hi' }, + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /pushPayload \(singular\) is removed in next\.4, use pushPayloads: \[yourPayload\]/); + }); + + it('rejects when BOTH pushPayload and pushPayloads are set', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'a' }, + pushPayloads: [{ messageKind: 'content', message: 'b' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /pushPayload \(singular\) is removed in next\.4, use pushPayloads/); + }); + + it('rejects pushPayloads: [] (empty array)', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayloads: [], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /use decision: skip-push to skip notification entirely/); + }); + + it('rejects a push that carries splitPattern', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'hi', splitPattern: '([。!?!?]+)' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /splitPattern is removed in next\.4/); + }); +}); From 2d4a7c579ae205ec88a6db75ce3e9975fc75e795 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 15:18:48 +0800 Subject: [PATCH 11/33] =?UTF-8?q?feat(amsg-instant)!:=200.8.0-next.4=20?= =?UTF-8?q?=E2=80=94=20sendPushesSequentially(pushPayloads)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook's finish/tool-request decisions now read `pushPayloads: PushPayload[]` and the lib delivers exactly that array in order with 1500ms spacing. Per-push: `messageId` is auto-filled when the hook didn't set one; `messageIndex` / `totalMessages` are always overwritten with the array-derived values. Removed: sendChunkedPush (only consumer was this branch). LOOP_EXCEEDED diagnostic now goes through sendPushWithMaybeBlob directly (it's one push by construction). `splitMessageIntoSentences` is no longer exported; still used internally by runLegacyInstant. splitHookPushPayload / pickSplitConfig / splitOnceByRegex / DEFAULT_SPLIT_REGEX / validateSplitPattern survive one more task so reasoning Layer-1 split keeps working — Task 4 wipes them as part of the reasoning rewrite. test/helpers.mjs: createFetchRouter gains a `pushHandler` option for per-call response control (used by the mid-array-throw test); existing `onPush` and the default 201 behaviour are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/src/message-processor.js | 84 ++++++++------ .../instant/test/agentic-loop.test.mjs | 106 ++++++++++++++++++ .../instant/test/helpers.mjs | 11 ++ 3 files changed, 165 insertions(+), 36 deletions(-) diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 5f426ca..771d609 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -86,7 +86,7 @@ function splitOnceByRegex(chunk, regex) { * @param {string | string[] | null} [splitPattern=null] * @returns {string[]} */ -export function splitMessageIntoSentences(messageContent, splitPattern = null) { +function splitMessageIntoSentences(messageContent, splitPattern = null) { const sources = splitPattern == null ? null : Array.isArray(splitPattern) ? splitPattern : @@ -312,31 +312,45 @@ function splitHookPushPayload(pushPayload, payload) { } /** - * Split `pushPayload` per kind and ship the chunks sequentially with - * `SLEEP_BETWEEN_MESSAGES_MS` spacing. Each chunk goes through - * `sendPushWithMaybeBlob` so the blob detour still applies per-chunk. + * Deliver `pushPayloads` sequentially via `sendPushWithMaybeBlob`, + * spacing `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) between consecutive + * pushes. Each push goes through `sendPushWithMaybeBlob` so the blob + * detour still applies per-push. * - * Returns the chunk count actually emitted (callers use this for - * `messagesSent` event payloads). Throws on the first chunk that - * fails delivery — caller decides whether that aborts the whole turn - * or is best-effort (e.g. auto-emitted reasoning is best-effort). + * Per-push auto-fill (mutates the push object in place — the hook + * returned a plain literal, we own it from this point): + * - `messageId` — only when the hook didn't set one (auto-fill + * with `msg__chunk_` so deduplication + * on the SW side works across retries). + * - `messageIndex` — always overwritten (1-based) with the array index. + * - `totalMessages` — always overwritten with `pushPayloads.length`. * - * @param {unknown} pushPayload + * Throws on the first failed push; subsequent pushes are not attempted. + * Callers decide whether to surface the throw or treat the partial + * delivery as best-effort. + * + * @param {Array>} pushPayloads * @param {Record} payload * @param {Object} ctx * @param {string} sessionId * @param {(ms: number) => Promise} sleep * @returns {Promise} */ -async function sendChunkedPush(pushPayload, payload, ctx, sessionId, sleep) { - const chunks = splitHookPushPayload(pushPayload, payload); - for (let i = 0; i < chunks.length; i++) { - await sendPushWithMaybeBlob(chunks[i], payload, ctx, sessionId); - if (i < chunks.length - 1) { +async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sleep) { + const total = pushPayloads.length; + for (let i = 0; i < total; i++) { + const push = pushPayloads[i]; + if (push.messageId === undefined) { + push.messageId = `msg_${randomUUID()}_chunk_${i}`; + } + push.messageIndex = i + 1; + push.totalMessages = total; + await sendPushWithMaybeBlob(push, payload, ctx, sessionId); + if (i < total - 1) { await sleep(SLEEP_BETWEEN_MESSAGES_MS); } } - return chunks.length; + return total; } // ─── Reasoning two-layer cascade ──────────────────────────────────────── @@ -974,22 +988,22 @@ async function runAgenticLoop(payload, ctx) { return { status: 'skipped', sessionId, iteration }; } - // 'finish' or 'tool-request' — both deliver a push, with optional - // sentence-split per `messageKind`: - // `content` / `tool_request` → `payload.splitPattern` (default on) - // `reasoning` → `payload.reasoningSplitPattern` (default off) - // `error` → `payload.errorSplitPattern` (default off) - // free-form pushPayload → never split - // Reasoning additionally goes through Layer 2 byte chunking - // (`ctx.reasoningChunkBytes`, default 2000 B) so a hook returning - // a single large ReasoningPush still ships under the Web Push - // payload limit without forcing the hook author to slice. - const isReasoning = decision.pushPayload - && typeof decision.pushPayload === 'object' - && /** @type {{messageKind?: unknown}} */ (decision.pushPayload).messageKind === 'reasoning'; - const messagesSent = isReasoning - ? await emitReasoning(decision.pushPayload, payload, ctx, sessionId, sleep, iteration) - : await sendChunkedPush(decision.pushPayload, payload, ctx, sessionId, sleep); + // 'finish' or 'tool-request' — deliver pushPayloads sequentially. + // The lib does no splitting; the hook returned the exact N pushes. + // Reasoning pushes coming from the hook flow through the same + // delivery path — `autoEmitReasoning` (default on) handles the + // framework-emitted ReasoningPush that comes from the LLM's + // `reasoning_content` field BEFORE the hook fires, and Task 4 + // rewires `emitReasoning` to a single-layer byte-chunker. Hooks + // wanting custom reasoning chunking now slice themselves and pass + // the pieces as individual `pushPayloads` entries. + const messagesSent = await sendPushesSequentially( + decision.pushPayloads, + payload, + ctx, + sessionId, + sleep, + ); onEvent({ type: decision.decision === 'finish' ? 'final_pushed' : 'tool_request_pushed', sessionId, @@ -1012,11 +1026,9 @@ async function runAgenticLoop(payload, ctx) { timestamp: new Date().toISOString(), }); try { - // Honour `payload.errorSplitPattern` so a caller that configured - // diagnostic chunking gets it consistently across hook-returned - // ErrorPushes and framework-emitted ones. Default-off → single - // push, same as pre-next.2. - await sendChunkedPush(diagnostic, payload, ctx, sessionId, sleep); + // The diagnostic is a single push by construction (one + // `buildErrorPush(...)` call above); no looping needed. + await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); } catch (err) { onEvent({ type: 'diagnostic_push_failed', code: 'LOOP_EXCEEDED', sessionId, cause: err }); } diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 0821c9b..57d9084 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -26,6 +26,7 @@ import { HookError, PayloadTooLargeError, MemoryStoreFullError, + processInstantMessage, validateContinuePayload, validateInstantPayload, } from '../src/index.js'; @@ -760,3 +761,108 @@ describe('next.4 — decision contract: pushPayloads', () => { assert.match(body.error.message, /splitPattern is removed in next\.4/); }); }); + +// ─── next.4 — pushPayloads happy paths ───────────────────────────────── + +describe('next.4 — pushPayloads happy paths', () => { + it('sends N pushes from a 3-element pushPayloads array with messageIndex/totalMessages auto-fill', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + }); + const sleeps = []; + const result = await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'first' }, + { messageKind: 'content', message: 'second' }, + { messageKind: 'content', message: 'third' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + assert.equal(result.status, 'finished'); + assert.equal(router.pushCalls.length, 3); + const decoded = []; + for (const c of router.pushCalls) decoded.push(JSON.parse(await decryptCapturedPushBody(c.body, subKit))); + assert.deepEqual(decoded.map(p => p.message), ['first', 'second', 'third']); + assert.deepEqual(decoded.map(p => p.messageIndex), [1, 2, 3]); + assert.deepEqual(decoded.map(p => p.totalMessages), [3, 3, 3]); + // 1500 between push 1↔2 and 2↔3 + assert.deepEqual(sleeps, [1500, 1500]); + }); + + it('preserves hook-set messageId, overwrites caller-set messageIndex/totalMessages', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + }); + await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a', messageId: 'custom-id-1', messageIndex: 99, totalMessages: 99 }, + { messageKind: 'content', message: 'b' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + const decoded = []; + for (const c of router.pushCalls) decoded.push(JSON.parse(await decryptCapturedPushBody(c.body, subKit))); + assert.equal(decoded[0].messageId, 'custom-id-1', 'caller messageId kept'); + assert.notEqual(decoded[1].messageId, decoded[0].messageId, 'auto messageId distinct'); + assert.equal(decoded[0].messageIndex, 1, 'lib overwrites caller messageIndex'); + assert.equal(decoded[0].totalMessages, 2, 'lib overwrites caller totalMessages'); + assert.equal(decoded[1].messageIndex, 2); + assert.equal(decoded[1].totalMessages, 2); + }); + + it('mid-array push failure aborts remaining pushes, no final_pushed event', async () => { + let pushIdx = 0; + const events = []; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + pushHandler: () => { + pushIdx++; + if (pushIdx === 2) { + return { ok: false, status: 502, statusText: 'Bad Gateway', async text() { return 'fail'; } }; + } + return { ok: true, status: 201, async text() { return ''; } }; + }, + }); + let caught; + try { + await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onEvent: (e) => events.push(e), + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'one' }, + { messageKind: 'content', message: 'two' }, + { messageKind: 'content', message: 'three' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + } catch (err) { + caught = err; + } + assert.ok(caught, 'mid-array failure should propagate'); + assert.equal(pushIdx, 2, 'second push attempted, third skipped'); + assert.equal(events.some(e => e.type === 'final_pushed'), false, 'no final_pushed on partial delivery'); + }); +}); diff --git a/packages/rei-standard-amsg/instant/test/helpers.mjs b/packages/rei-standard-amsg/instant/test/helpers.mjs index db53a3d..c944133 100644 --- a/packages/rei-standard-amsg/instant/test/helpers.mjs +++ b/packages/rei-standard-amsg/instant/test/helpers.mjs @@ -145,6 +145,13 @@ async function hkdf(salt, ikm, info, length) { * @param {(url: string, init: RequestInit) => Promise Promise, text?: () => Promise }>} [routes.llm] * @param {string} routes.pushEndpoint - Subscription endpoint to intercept. * @param {(url: string, init: RequestInit, captured: { body: Uint8Array, headers: Record }) => any} [routes.onPush] + * @param {(captured: { url: string, body: Uint8Array, headers: Record, callIndex: number }) => any} [routes.pushHandler] + * Per-call response override for the push endpoint. Called with a + * captured object that includes `callIndex` (1-based) so the handler + * can inject mid-array failures. Return a Response-like object (with + * `ok`/`status`/`statusText`/`text()`) to override the default 201; + * return falsy to fall through to the default. Takes precedence over + * `onPush` when both are supplied. * @returns {{ fetch: Function, pushCalls: Array<{ url: string, body: Uint8Array, headers: Record }> }} */ export function createFetchRouter(routes) { @@ -159,6 +166,10 @@ export function createFetchRouter(routes) { ); const captured = { url, body: bodyBytes, headers }; pushCalls.push(captured); + if (typeof routes.pushHandler === 'function') { + const r = await routes.pushHandler({ ...captured, callIndex: pushCalls.length }); + if (r) return r; + } if (typeof routes.onPush === 'function') { const r = await routes.onPush(url, init, captured); if (r) return r; From b68e249b56d9efb19f22b46f1c6e24c667ec8a84 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 15:34:23 +0800 Subject: [PATCH 12/33] =?UTF-8?q?feat(amsg-instant)!:=200.8.0-next.4=20?= =?UTF-8?q?=E2=80=94=20reasoning=20auto-emit=20single-layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitReasoning is now a single transform: byte chunking via chunkReasoningByUtf8Bytes when reasoningContent exceeds ctx.reasoningChunkBytes, single-push passthrough otherwise. The Layer-1 sentence split (reasoningSplitPattern) is gone — callers wanting sentence-level reasoning chunks should disable autoEmitReasoning and build the pushes themselves. Removed: splitHookPushPayload, pickSplitConfig, expandReasoningPushChunks, validateSplitPattern, SPLIT_PATTERN_MAX_LENGTH, SPLIT_PATTERN_MAX_ITEMS, and the validateSplitPattern import in message-processor.js. All five survived Task 3 only because expandReasoningPushChunks still called splitHookPushPayload for the now-deleted Layer-1 reasoning split. splitMessageIntoSentences (and its private helpers DEFAULT_SPLIT_REGEX + splitOnceByRegex) kept — runLegacyInstant still applies the default sentence regex to content. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/src/message-processor.js | 380 +++--------------- .../instant/src/validation.js | 37 -- .../instant/test/reasoning-push.test.mjs | 49 +++ 3 files changed, 106 insertions(+), 360 deletions(-) diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 771d609..ce8d869 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -26,11 +26,10 @@ import { sendWebPush } from './webpush.js'; import { randomUUID } from './utils.js'; import { HookError, LlmCallError, PayloadTooLargeError } from './errors.js'; import { buildSessionContext, extractAssistantMessage } from './session-context.js'; -import { validateSplitPattern } from './validation.js'; const SLEEP_BETWEEN_MESSAGES_MS = 1500; -// Sub-chunk spacing within a single Layer-1 segment. Byte-chunking is -// a transport-level workaround (Web Push payload limit), NOT a +// Inter-chunk spacing for reasoning byte chunks. Byte-chunking is a +// transport-level workaround (Web Push payload limit), NOT a // typing-bubble UX axis, so the inter-chunk gap is much smaller than // the inter-sentence gap. 100 ms is enough to avoid pummelling the // push gateway in a tight loop while keeping perceived latency low. @@ -79,8 +78,9 @@ function splitOnceByRegex(chunk, regex) { * — e.g. `"([\\n]+)"` not `"[\\n]+"`. We don't auto-wrap; that would require * parsing escaped/character-class/non-capturing groups. * - * Validation (length cap, regex compilability, array size) is enforced by - * `validateSplitPattern` upstream — this function trusts its inputs. + * Internal to `runLegacyInstant` — the legacy path still applies the + * default sentence regex to content. The hook path no longer splits + * (Task 4); hooks return the exact pushPayloads they want delivered. * * @param {string} messageContent * @param {string | string[] | null} [splitPattern=null] @@ -104,213 +104,6 @@ function splitMessageIntoSentences(messageContent, splitPattern = null) { return chunks.length > 0 ? chunks : [messageContent]; } -/** - * Pick the right split-pattern field + disable semantics for a given - * `messageKind`. The three kinds split with different defaults: - * - * | messageKind | field on payload | default when field is absent | - * |----------------|---------------------------|------------------------------| - * | `content` | `splitPattern` | sentence regex (split on) | - * | `tool_request` | `splitPattern` | sentence regex (split on) | - * | `reasoning` | `reasoningSplitPattern` | **no split** | - * | `error` | `errorSplitPattern` | **no split** | - * - * Disable semantics in all four cases: an explicit `null` or `[]` - * disables splitting. The asymmetry is in `undefined` (i.e. caller - * omitted the field): for `content` / `tool_request` that means "use - * default sentence regex" (preserves 0.6 UX); for `reasoning` / - * `error` it means "do not split" (the kinds that didn't have a UX - * for splitting historically). - * - * Per-push override (0.8.0-next.3+): when the hook returns a - * `pushPayload` that owns a `splitPattern` field, the resolved - * `pushPattern` argument carries that value and takes precedence over - * the kind-specific request field above. The override is kind- - * agnostic (just `splitPattern` — the kind is already pinned by - * `pushPayload.messageKind`), so disable semantics collapse to the - * shared `null` / `[]` rule and never fall back to default-on. Callers - * pass `pushOverridePresent = false` when the field is absent so the - * request-level fallback kicks in. - * - * @param {Record} payload - * @param {unknown} kind - * @param {unknown} pushPattern - The hook-returned override (only consulted when `pushOverridePresent`). - * @param {boolean} pushOverridePresent - True iff `pushPayload` owned `splitPattern` (even when `null`). - * @returns {{ textField: 'message' | 'reasoningContent', pattern: unknown, disabled: boolean } | null} - * `null` when the kind is not splittable (unknown / free-form kinds - * with no override). An override on a free-form kind still applies - * when there's a usable text field — see `splitHookPushPayload`. - */ -function pickSplitConfig(payload, kind, pushPattern, pushOverridePresent) { - // Resolve text-field by kind. Per-push override flips disable - // semantics to "explicit null/[] = off; anything else = use it". - const resolveDisabled = (pattern) => - pattern === null || (Array.isArray(pattern) && pattern.length === 0); - - if (kind === 'content' || kind === 'tool_request') { - if (pushOverridePresent) { - return { textField: 'message', pattern: pushPattern, disabled: resolveDisabled(pushPattern) }; - } - const pattern = payload.splitPattern; - const disabled = pattern === null - || (Array.isArray(pattern) && pattern.length === 0); - return { textField: 'message', pattern, disabled }; - } - if (kind === 'reasoning') { - if (pushOverridePresent) { - // Per-push override skips the request-level "default-off" - // asymmetry: if the hook went out of its way to set the field - // on the push, treat any non-null/non-[] value as "split with - // this pattern" — same rule as content/tool_request override. - return { textField: 'reasoningContent', pattern: pushPattern, disabled: resolveDisabled(pushPattern) }; - } - const pattern = payload.reasoningSplitPattern; - // Default-off: undefined / null / [] all mean "do not split". - const disabled = pattern === undefined - || pattern === null - || (Array.isArray(pattern) && pattern.length === 0); - return { textField: 'reasoningContent', pattern, disabled }; - } - if (kind === 'error') { - if (pushOverridePresent) { - return { textField: 'message', pattern: pushPattern, disabled: resolveDisabled(pushPattern) }; - } - const pattern = payload.errorSplitPattern; - // Default-off, same as reasoning. - const disabled = pattern === undefined - || pattern === null - || (Array.isArray(pattern) && pattern.length === 0); - return { textField: 'message', pattern, disabled }; - } - return null; -} - -/** - * Per-kind splitter. Given a `pushPayload` and the request `payload` - * (which carries the kind-specific split-pattern fields), apply the - * right pattern to the kind's text field and return an array of - * one-per-push payloads ready for sequential delivery. - * - * Routing per `messageKind`: - * - `'content'` → reads `payload.splitPattern`, splits `message` - * - `'reasoning'` → reads `payload.reasoningSplitPattern`, splits - * `reasoningContent` (default-off) - * - `'tool_request'` → reads `payload.splitPattern`, splits - * `message`; `toolCalls` binds to the LAST - * prefix chunk (emitted as `tool_request`). - * Chunks 0..N-2 are demoted to `messageKind: - * 'content'` (without `toolCalls`) so the - * narration finishes BEFORE the client starts - * executing tools. - * - `'error'` → reads `payload.errorSplitPattern`, splits - * `message` (default-off) - * - anything else → passthrough; the framework can't guess which - * field of a free-form hook payload to split. - * - * Per-push override (0.8.0-next.3+): when the hook-returned - * `pushPayload` owns a `splitPattern` field, it takes precedence over - * the kind-specific request field — including disabling the default - * split with `splitPattern: null` on a `content` push. The field is - * shape-validated (same caps as request-level via - * `validateSplitPattern`); malformed override throws `HookError`. The - * directive is stripped before delivery so it never appears on the - * wire, regardless of whether the split actually fired. - * - * The original payload's `toolCalls`, `metadata`, and all push - * metadata fields (`messageType` / `source` / `sessionId` / `timestamp` - * / `messageKind` / `messageSubtype` / `taskId`) are preserved - * verbatim per chunk. Only `messageId` is regenerated per chunk - * (independent IDs, shared sessionId) and `messageIndex` / - * `totalMessages` are populated 1-based. - * - * @param {unknown} pushPayload - * @param {Record} payload - The validated request payload. - * @returns {Array} - Length ≥ 1. Single-element when not - * splittable or when the split produces - * one chunk (so callers always loop). - */ -function splitHookPushPayload(pushPayload, payload) { - if (!pushPayload || typeof pushPayload !== 'object' || Array.isArray(pushPayload)) { - return [pushPayload]; - } - const pushObj = /** @type {Record} */ (pushPayload); - const kind = pushObj.messageKind; - - // Extract + validate the per-push override (if any) and produce a - // clean copy of pushObj that never carries `splitPattern` downstream - // — both single-chunk passthrough returns and N-chunk maps below - // use `cleanPushObj` so the directive can't leak onto the wire. - // - // `undefined` is treated as **absent** (same convention as - // request-level fields and as plain JS "value not really set"), so - // `pushPayload.splitPattern: undefined` falls back to the request- - // level field rather than being interpreted as a degenerate - // override. Only a non-`undefined` value (including `null` / `[]`) - // counts as an override. `JSON.stringify` already drops `undefined` - // properties at the wire layer, so the `undefined` case needs no - // explicit strip. - const pushPattern = pushObj.splitPattern; - const pushOverridePresent = pushPattern !== undefined; - let cleanPushObj = pushObj; - if (pushOverridePresent) { - const validationErr = validateSplitPattern(pushPattern); - if (validationErr) { - // Same severity as other pushPayload-shape contract violations - // (see `sendPushWithMaybeBlob`): surface as HookError so the - // caller's hook author sees a loud failure instead of a silent - // unsplit push. `validateSplitPattern` labels its errors with - // the literal "splitPattern" prefix (shared with the request- - // level validator) — strip it so the HookError doesn't read - // "pushPayload.splitPattern invalid: splitPattern ...". - const cleanedErr = validationErr.replace( - /^splitPattern(\[\d+\])?\s*/, - (_m, idx) => idx ? `${idx} ` : '', - ); - throw new HookError(`pushPayload.splitPattern invalid: ${cleanedErr}`); - } - const { splitPattern: _strip, ...rest } = pushObj; - cleanPushObj = rest; - } - - const cfg = pickSplitConfig(payload || {}, kind, pushPattern, pushOverridePresent); - if (!cfg || cfg.disabled) return [cleanPushObj]; - - const text = cleanPushObj[cfg.textField]; - if (typeof text !== 'string' || text.length === 0) return [cleanPushObj]; - - const segments = splitMessageIntoSentences(text, cfg.pattern); - if (segments.length <= 1) return [cleanPushObj]; - - const total = segments.length; - return segments.map((segment, i) => { - const isLast = i === total - 1; - const chunkMessageId = `msg_${randomUUID()}_chunk_${i}`; - - if (kind === 'tool_request' && !isLast) { - // Demote prefix chunks to ContentPush — drop `toolCalls` so the - // client UI doesn't try to execute the tool N times. The last - // chunk (below) keeps the original kind + toolCalls intact. - const { toolCalls: _drop, ...rest } = cleanPushObj; - return { - ...rest, - messageKind: 'content', - messageId: chunkMessageId, - message: segment, - messageIndex: i + 1, - totalMessages: total, - }; - } - - return { - ...cleanPushObj, - messageId: chunkMessageId, - [cfg.textField]: segment, - messageIndex: i + 1, - totalMessages: total, - }; - }); -} - /** * Deliver `pushPayloads` sequentially via `sendPushWithMaybeBlob`, * spacing `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) between consecutive @@ -353,100 +146,60 @@ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sle return total; } -// ─── Reasoning two-layer cascade ──────────────────────────────────────── +// ─── Reasoning byte chunking ──────────────────────────────────────────── /** - * Expand a single `ReasoningPush` into the flat leaf array of pushes - * the framework will actually deliver, applying the two-layer cascade: - * - * Layer 1 — semantic split via `payload.reasoningSplitPattern` - * (delegates to `splitHookPushPayload`). Default-off; - * when set, produces M segments carrying - * `messageIndex` / `totalMessages`. - * - * Layer 2 — UTF-8 byte chunking via `reasoningChunkBytes` ctx - * knob. Default-on (threshold 2000 B); when a Layer-1 - * segment exceeds the threshold, the segment is sliced at - * codepoint boundaries (via `chunkReasoningByUtf8Bytes`) - * into N sub-pushes carrying `chunkIndex` / `totalChunks`. - * `null` disables Layer 2 entirely — oversized segments - * then fall through to `sendPushWithMaybeBlob` and either - * hit BlobStore (if configured) or throw - * `PayloadTooLargeError`. + * Slice a ReasoningPush into one or more byte-bounded pushes. When + * `reasoningContent` UTF-8 length exceeds `reasoningChunkBytes`, the + * lib chunks at UTF-8 codepoint boundaries via + * `chunkReasoningByUtf8Bytes` and ships each chunk as its own push + * with `chunkIndex` / `totalChunks`. Otherwise ships as one. * - * Layer 1 fields (messageIndex / totalMessages) come straight from - * `splitHookPushPayload`. Layer 2 fields (chunkIndex / totalChunks) - * are added per-leaf when N > 1; otherwise the leaf wire-matches the - * pre-byte-chunking shape byte-for-byte. + * `null` for `reasoningChunkBytes` disables chunking entirely — + * oversized reasoning then either flows through `sendPushWithMaybeBlob` + * (and BlobStore) or throws `PayloadTooLargeError`. * * `messageId` is regenerated per leaf so each push has a unique id: * `msg__iter__reasoning_chunk_` * * @param {Object} reasoningPush - * @param {Object} payload * @param {number | null | undefined} reasoningChunkBytes - * @param {number | undefined} iteration - 0 for legacy path, the agentic-loop iteration otherwise. + * @param {number | undefined} iteration * @returns {Array} */ -function expandReasoningPushChunks(reasoningPush, payload, reasoningChunkBytes, iteration) { - // Layer 1: defer to the shared splitter. Returns 1 element when - // `reasoningSplitPattern` is unset/disabled; ≥2 when sentence split - // produces multiple segments. - const layer1 = splitHookPushPayload(reasoningPush, payload); - - // Resolve the byte threshold: - // - `null` → Layer 2 explicitly disabled - // - `undefined`→ ctx didn't carry the resolved option (callers that - // invoke `processInstantMessage` directly, e.g. tests). - // Fall back to the same default as `createInstantHandler`. - // - positive integer → use as threshold - if (reasoningChunkBytes === null) return layer1; +function sliceReasoningPush(reasoningPush, reasoningChunkBytes, iteration) { + if (reasoningChunkBytes === null) return [reasoningPush]; const threshold = (Number.isInteger(reasoningChunkBytes) && reasoningChunkBytes >= 4) ? reasoningChunkBytes : DEFAULT_REASONING_CHUNK_BYTES; - /** @type {Array} */ - const out = []; - for (const segment of layer1) { - const text = segment && typeof segment === 'object' - ? /** @type {{reasoningContent?: unknown}} */ (segment).reasoningContent - : undefined; - if (typeof text !== 'string' || text.length === 0) { - out.push(segment); - continue; - } - const byteLen = PUSH_PAYLOAD_BYTE_ENCODER.encode(text).byteLength; - if (byteLen <= threshold) { - out.push(segment); - continue; - } - const pieces = chunkReasoningByUtf8Bytes(text, threshold); - const totalChunks = pieces.length; - const iterTag = Number.isInteger(iteration) ? iteration : 0; - for (let i = 0; i < totalChunks; i++) { - out.push({ - ...segment, - messageId: `msg_${randomUUID()}_iter_${iterTag}_reasoning_chunk_${i + 1}`, - reasoningContent: pieces[i], - chunkIndex: i + 1, - totalChunks, - }); - } - } - return out; + const text = typeof reasoningPush.reasoningContent === 'string' + ? reasoningPush.reasoningContent + : ''; + if (!text) return [reasoningPush]; + + const byteLen = PUSH_PAYLOAD_BYTE_ENCODER.encode(text).byteLength; + if (byteLen <= threshold) return [reasoningPush]; + + const pieces = chunkReasoningByUtf8Bytes(text, threshold); + const totalChunks = pieces.length; + const iterTag = Number.isInteger(iteration) ? iteration : 0; + return pieces.map((piece, i) => ({ + ...reasoningPush, + messageId: `msg_${randomUUID()}_iter_${iterTag}_reasoning_chunk_${i + 1}`, + reasoningContent: piece, + chunkIndex: i + 1, + totalChunks, + })); } /** - * Ship a `ReasoningPush` through the two-layer cascade. Serial - * delivery with `SLEEP_BETWEEN_REASONING_CHUNKS_MS` (100 ms) between - * Layer-2 chunks of the same Layer-1 segment, and - * `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) between Layer-1 segments — - * the larger gap preserves typing-bubble UX between sentences while - * the smaller gap keeps byte-chunking latency low. - * - * Fires a single `reasoning_chunked` event when Layer 2 actually - * produces > 1 chunk (independent of Layer 1 count) so operators see - * the byte-chunking trigger without per-chunk event noise. + * Ship a ReasoningPush, byte-chunking if oversized. Fires a single + * `reasoning_chunked` event when chunking actually splits the push. + * Serial delivery with `SLEEP_BETWEEN_REASONING_CHUNKS_MS` (100 ms) + * between chunks — byte chunking is a transport-level workaround, not + * a typing-bubble UX axis, so the inter-chunk gap is much smaller than + * the inter-sentence gap. * * @param {Object} reasoningPush * @param {Object} payload @@ -457,16 +210,9 @@ function expandReasoningPushChunks(reasoningPush, payload, reasoningChunkBytes, * @returns {Promise} Total leaves shipped. */ async function emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration) { - const leaves = expandReasoningPushChunks(reasoningPush, payload, ctx.reasoningChunkBytes, iteration); - - // Detect "byte chunking actually fired" — i.e. at least one leaf - // carries chunkIndex/totalChunks. We don't fire on Layer-1-only - // splits (those are user-configured semantic splits, not transport - // overflow events). - const byteChunked = leaves.some( - (l) => l && typeof l === 'object' && /** @type {{totalChunks?: unknown}} */ (l).totalChunks !== undefined - ); - if (byteChunked) { + const leaves = sliceReasoningPush(reasoningPush, ctx.reasoningChunkBytes, iteration); + + if (leaves.length > 1) { const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; const totalBytes = typeof reasoningPush.reasoningContent === 'string' ? PUSH_PAYLOAD_BYTE_ENCODER.encode(reasoningPush.reasoningContent).byteLength @@ -479,17 +225,7 @@ async function emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iter for (let i = 0; i < leaves.length; i++) { await sendPushWithMaybeBlob(leaves[i], payload, ctx, sessionId); if (i < leaves.length - 1) { - // Same Layer-1 segment iff messageIndex matches (or neither has - // one — Layer 1 was disabled, all leaves are byte chunks of a - // single segment). - const cur = leaves[i]; - const next = leaves[i + 1]; - const curIdx = cur && typeof cur === 'object' - ? /** @type {{messageIndex?: unknown}} */ (cur).messageIndex : undefined; - const nextIdx = next && typeof next === 'object' - ? /** @type {{messageIndex?: unknown}} */ (next).messageIndex : undefined; - const sameSegment = curIdx === nextIdx; - await sleep(sameSegment ? SLEEP_BETWEEN_REASONING_CHUNKS_MS : SLEEP_BETWEEN_MESSAGES_MS); + await sleep(SLEEP_BETWEEN_REASONING_CHUNKS_MS); } } return leaves.length; @@ -769,10 +505,10 @@ async function runLegacyInstant(payload, ctx) { // user-facing content burst. Mirrors the hook path's // `reasoning_push_failed` event (runAgenticLoop). // - // Two-layer cascade via `emitReasoning`: - // Layer 1 — `payload.reasoningSplitPattern` (default off, sentence split) - // Layer 2 — `ctx.reasoningChunkBytes` (default 2000, byte chunking) - // Single reasoning < threshold + no sentence pattern → wire matches pre-next.2 exactly. + // `emitReasoning` byte-chunks via `ctx.reasoningChunkBytes` + // (default 2000 B): short reasoning ships as a single push; + // oversized reasoning is sliced into N chunks at UTF-8 codepoint + // boundaries with `chunkIndex` / `totalChunks`. let reasoningShipped = false; try { // Legacy path has no "iteration" — pass undefined so messageId @@ -918,11 +654,9 @@ async function runAgenticLoop(payload, ctx) { metadata: payload.metadata || {}, }); try { - // Two-layer cascade — Layer 1 (`reasoningSplitPattern`, - // default off) then Layer 2 (`reasoningChunkBytes`, default - // 2000 B). Default config: single short reasoning ships as - // one push (wire-identical to pre-next.2); long reasoning - // auto-chunks with `chunkIndex`/`totalChunks`. + // Byte-chunk via `ctx.reasoningChunkBytes` (default 2000 B): + // short reasoning ships as one push; oversized reasoning is + // sliced into N chunks with `chunkIndex`/`totalChunks`. await emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration); onEvent({ type: 'reasoning_pushed', sessionId, iteration }); } catch (err) { @@ -991,12 +725,12 @@ async function runAgenticLoop(payload, ctx) { // 'finish' or 'tool-request' — deliver pushPayloads sequentially. // The lib does no splitting; the hook returned the exact N pushes. // Reasoning pushes coming from the hook flow through the same - // delivery path — `autoEmitReasoning` (default on) handles the + // delivery path. `autoEmitReasoning` (default on) handles the // framework-emitted ReasoningPush that comes from the LLM's - // `reasoning_content` field BEFORE the hook fires, and Task 4 - // rewires `emitReasoning` to a single-layer byte-chunker. Hooks - // wanting custom reasoning chunking now slice themselves and pass - // the pieces as individual `pushPayloads` entries. + // `reasoning_content` field BEFORE the hook fires; `emitReasoning` + // is a single-layer byte-chunker. Hooks wanting custom reasoning + // chunking slice themselves and pass the pieces as individual + // `pushPayloads` entries. const messagesSent = await sendPushesSequentially( decision.pushPayloads, payload, diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index a5a1253..5498b4b 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -47,43 +47,6 @@ export function validateAvatarUrl(value) { return null; } -const SPLIT_PATTERN_MAX_LENGTH = 200; -const SPLIT_PATTERN_MAX_ITEMS = 10; - -/** - * Validate the optional `splitPattern` field. Mirrors amsg-server's - * `validateSplitPattern` (kept in lockstep). Accepts string, string[], or - * absent/null. Returns an error message string, or null when valid. - * - * As of next.4 the three request-body fields (`splitPattern` / - * `reasoningSplitPattern` / `errorSplitPattern`) are rejected outright - * in `validateInstantPayload` / `validateContinuePayload` — only the - * per-push override `pushPayload.splitPattern` returned by hook authors - * still reaches this validator (via `splitHookPushPayload` in - * `message-processor.js`). Task 2/3 of the next.4 migration removes - * that last call site; this helper goes with it. - */ -export function validateSplitPattern(value) { - if (value === undefined || value === null) return null; - const isArray = Array.isArray(value); - const items = isArray ? value : [value]; - if (isArray && items.length === 0) return null; // empty array = use default - if (items.length > SPLIT_PATTERN_MAX_ITEMS) { - return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`; - } - for (let i = 0; i < items.length; i++) { - const s = items[i]; - const label = isArray ? `splitPattern[${i}]` : 'splitPattern'; - if (typeof s !== 'string') return `${label} 必须是字符串`; - if (s.length > SPLIT_PATTERN_MAX_LENGTH) { - return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`; - } - try { new RegExp(s); } - catch (_) { return `${label} 不是有效正则表达式`; } - } - return null; -} - function validateMessagesArray(messages) { if (!Array.isArray(messages) || messages.length === 0) { return 'messages 必须是长度 ≥ 1 的数组'; diff --git a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs index fbd0c3f..f121902 100644 --- a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs +++ b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs @@ -243,3 +243,52 @@ describe('hook path — ReasoningPush auto-emission', () => { assert.equal(decoded[1].sessionId, 'sess-stable-1'); }); }); + +// ─── next.4 — reasoning byte-chunking simplified ─────────────────────── + +describe('next.4 — reasoning byte-chunking simplified', () => { + it('short reasoning ships as a single push (no chunkIndex on wire)', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('hi.', { reasoning_content: 'short thought' }), + }); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + await handler(makeRequest(basePayload())); + const decoded = await decryptAll(router.pushCalls); + const r = decoded.find(p => p.messageKind === 'reasoning'); + assert.ok(r, 'expected a reasoning push'); + assert.equal('chunkIndex' in r, false); + assert.equal('totalChunks' in r, false); + assert.equal('messageIndex' in r, false, 'no Layer-1 split → no messageIndex'); + assert.equal(r.reasoningContent, 'short thought'); + }); + + it('oversized reasoning gets byte-chunked into N pushes with chunkIndex/totalChunks', async () => { + const big = 'x'.repeat(5500); // > default 2000 B threshold + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('hi.', { reasoning_content: big }), + }); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (e) => events.push(e), + }); + await handler(makeRequest(basePayload())); + const decoded = await decryptAll(router.pushCalls); + const reasoning = decoded.filter(p => p.messageKind === 'reasoning'); + assert.ok(reasoning.length >= 3, `expected >= 3 chunks for 5500B reasoning at 2000B threshold, got ${reasoning.length}`); + for (let i = 0; i < reasoning.length; i++) { + assert.equal(reasoning[i].chunkIndex, i + 1); + assert.equal(reasoning[i].totalChunks, reasoning.length); + } + // Reassembling yields the original + const reassembled = reasoning.map(p => p.reasoningContent).join(''); + assert.equal(reassembled, big); + // reasoning_chunked event fires exactly once + const chunkedEvts = events.filter(e => e.type === 'reasoning_chunked'); + assert.equal(chunkedEvts.length, 1); + assert.equal(chunkedEvts[0].totalChunks, reasoning.length); + }); +}); From b0a484616201642775eaa81e7f43390cdf2d5596 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 15:42:20 +0800 Subject: [PATCH 13/33] docs(amsg-instant): justify reasoning floor + document undefined-iteration fallback + no-op event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups to b68e249. Three pure-doc additions in message-processor.js: - Comment on `>= 4` threshold floor explaining the shared-lib RangeError contract it's mirroring. - JSDoc on sliceReasoningPush's `iteration` param documenting the legacy undefined → iter_0 fallback. - JSDoc on emitReasoning noting that reasoning_chunked does not fire on single-push (no-op chunking) emissions. No logic change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/src/message-processor.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index ce8d869..a35ccf7 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -164,11 +164,17 @@ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sle * * @param {Object} reasoningPush * @param {number | null | undefined} reasoningChunkBytes - * @param {number | undefined} iteration + * @param {number | undefined} iteration - Legacy path passes `undefined`; + * the messageId template falls back to `iter_0` in that case. Hook path + * always passes an integer iteration counter. * @returns {Array} */ function sliceReasoningPush(reasoningPush, reasoningChunkBytes, iteration) { if (reasoningChunkBytes === null) return [reasoningPush]; + // `chunkReasoningByUtf8Bytes` from @rei-standard/amsg-shared requires + // maxBytes >= 4 (UTF-8 max codepoint width); honour that floor here so a + // pathological ctx.reasoningChunkBytes injection falls back to the default + // instead of crashing inside the shared lib. const threshold = (Number.isInteger(reasoningChunkBytes) && reasoningChunkBytes >= 4) ? reasoningChunkBytes : DEFAULT_REASONING_CHUNK_BYTES; @@ -196,6 +202,9 @@ function sliceReasoningPush(reasoningPush, reasoningChunkBytes, iteration) { /** * Ship a ReasoningPush, byte-chunking if oversized. Fires a single * `reasoning_chunked` event when chunking actually splits the push. + * No event fires when chunking is a no-op (single push) — the event is + * meant to signal Layer-2 byte chunking actually triggered, not just + * normal reasoning emission. * Serial delivery with `SLEEP_BETWEEN_REASONING_CHUNKS_MS` (100 ms) * between chunks — byte chunking is a transport-level workaround, not * a typing-bubble UX axis, so the inter-chunk gap is much smaller than From db2496730d0e91a2aa42eebe563a331f1c4e7045 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 15:48:22 +0800 Subject: [PATCH 14/33] test(amsg-instant)!: migrate hook tests to pushPayloads array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical s/pushPayload:/pushPayloads: [/ on every finish/tool-request hook return across agentic-loop, reasoning-push, and the agentic-loop-skeleton example. The X inside each wrapper is byte-for-byte preserved; only the key name + array wrap change. Two agentic-loop assertions pinned the OLD "no auto-fill" behaviour and needed updating to match next.4's sendPushesSequentially: - "pushes the hook-returned payload and returns finished" — deep-equal on the decrypted body now includes auto-injected messageId / messageIndex:1 / totalMessages:1 (single-element array). - "ASCII length 2600 → direct push; length 2601 → blob" — JSON overhead recomputed to account for the three auto-fill fields the framework adds before the byte-cap check. split-pattern-hook.test.mjs intentionally untouched — Task 6 deletes the whole file. handler.test.mjs's splitMessageIntoSentences / splitPattern blocks are still slated for Task 6 deletion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../examples/agentic-loop-skeleton/worker.js | 8 ++-- .../instant/test/agentic-loop.test.mjs | 45 ++++++++++++------- .../instant/test/reasoning-push.test.mjs | 6 +-- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js b/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js index 3cb68f2..94d9b59 100644 --- a/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js +++ b/packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js @@ -53,7 +53,7 @@ async function onLLMOutput(ctx) { if (text.includes('NEED_TOOL')) { return { decision: 'tool-request', - pushPayload: buildToolRequestPush({ + pushPayloads: [buildToolRequestPush({ messageType: 'instant', source: 'instant', messageId: `msg_${crypto.randomUUID()}_tool`, @@ -67,7 +67,7 @@ async function onLLMOutput(ctx) { contactName: ctx.contactName, // SW can include arbitrary client routing state in metadata. metadata: { iteration: ctx.iteration, messages: ctx.messages }, - }), + })], }; } @@ -97,7 +97,7 @@ async function onLLMOutput(ctx) { // hook contract is `pushPayload: unknown`. return { decision: 'finish', - pushPayload: buildContentPush({ + pushPayloads: [buildContentPush({ messageType: 'instant', source: 'instant', messageId: `msg_${crypto.randomUUID()}_content_0`, @@ -109,7 +109,7 @@ async function onLLMOutput(ctx) { messageIndex: 1, totalMessages: 1, taskId: null, - }), + })], }; } diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 57d9084..0140723 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -94,7 +94,7 @@ describe('agentic loop — decision: finish', () => { fetch: router.fetch, onLLMOutput: (ctx) => ({ decision: 'finish', - pushPayload: { type: 'custom', text: ctx.llmOutputText }, + pushPayloads: [{ type: 'custom', text: ctx.llmOutputText }], }), }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -103,7 +103,13 @@ describe('agentic loop — decision: finish', () => { assert.equal(body.data.status, 'finished'); assert.equal(router.pushCalls.length, 1); const decrypted = await decryptCapturedPushBody(router.pushCalls[0].body, subKit); - assert.deepEqual(JSON.parse(decrypted), { type: 'custom', text: 'hi there' }); + const decoded = JSON.parse(decrypted); + assert.equal(decoded.type, 'custom'); + assert.equal(decoded.text, 'hi there'); + // sendPushesSequentially auto-fills these on every push. + assert.equal(decoded.messageIndex, 1); + assert.equal(decoded.totalMessages, 1); + assert.match(decoded.messageId, /^msg_[0-9a-f-]+_chunk_0$/); }); }); @@ -120,7 +126,7 @@ describe('agentic loop — decision: tool-request', () => { fetch: router.fetch, onLLMOutput: () => ({ decision: 'tool-request', - pushPayload: { type: 'tool-request', tool: 'get_weather' }, + pushPayloads: [{ type: 'tool-request', tool: 'get_weather' }], }), }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -154,7 +160,7 @@ describe('agentic loop — decision: continue → finish', () => { }; } observedHistory = ctx.messages; - return { decision: 'finish', pushPayload: { type: 'done' } }; + return { decision: 'finish', pushPayloads: [{ type: 'done' }] }; }, }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -294,7 +300,7 @@ describe('agentic loop — hook contract violations', () => { fetch: router.fetch, onLLMOutput: () => ({ decision: 'finish', - pushPayload: { type: 'finish', big: 1n }, + pushPayloads: [{ type: 'finish', big: 1n }], }), }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -318,7 +324,7 @@ describe('agentic loop — SessionContext does not expose credentials', () => { fetch: router.fetch, onLLMOutput: (ctx) => { captured = ctx; - return { decision: 'finish', pushPayload: { ok: true } }; + return { decision: 'finish', pushPayloads: [{ ok: true }] }; }, }); await handler(makeRequest('http://h/instant', basePayload())); @@ -344,9 +350,18 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { llm: () => makeLlmResponse('x'), }); const blobAdapter = createMemoryBlobStore(); - // Build a JSON string of *exactly* `len` UTF-8 bytes: - // `{"type":"x","p":"...payload..."}` — fixed overhead 17 chars. - const overhead = '{"type":"x","p":""}'.length; + // Build a JSON string of *exactly* `len` UTF-8 bytes after the + // sendPushesSequentially auto-fill mutates the push object. + // Final shape: {"type":"x","p":"...","messageId":"msg__chunk_0","messageIndex":1,"totalMessages":1} + // messageId is the only variable-width field; its UUID is 36 chars, + // so messageId value length is `msg_`.length + 36 + `_chunk_0`.length = 48. + const overhead = JSON.stringify({ + type: 'x', + p: '', + messageId: 'm'.repeat(48), + messageIndex: 1, + totalMessages: 1, + }).length; const filler = 'a'.repeat(len - overhead); const handler = createInstantHandler({ vapid, @@ -354,7 +369,7 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { blobStore: { adapter: blobAdapter }, onLLMOutput: () => ({ decision: 'finish', - pushPayload: { type: 'x', p: filler }, + pushPayloads: [{ type: 'x', p: filler }], }), }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -388,7 +403,7 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { blobStore: { adapter: blobAdapter }, onLLMOutput: () => ({ decision: 'finish', - pushPayload: { type: 'cjk', p: cjk }, + pushPayloads: [{ type: 'cjk', p: cjk }], }), }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -409,7 +424,7 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { onEvent: (e) => events.push(e), onLLMOutput: () => ({ decision: 'finish', - pushPayload: { type: 'big', p: 'a'.repeat(5000) }, + pushPayloads: [{ type: 'big', p: 'a'.repeat(5000) }], }), }); const res = await handler(makeRequest('http://h/instant', basePayload())); @@ -435,7 +450,7 @@ describe('/blob/:key endpoint', () => { blobStore: { adapter: blobAdapter }, onLLMOutput: () => ({ decision: 'finish', - pushPayload: { type: 'big', p: 'a'.repeat(5000) }, + pushPayloads: [{ type: 'big', p: 'a'.repeat(5000) }], }), }); await handler(makeRequest('http://h/instant', basePayload())); @@ -548,12 +563,12 @@ describe('/continue endpoint', () => { if (ctx.iteration === 0) { return { decision: 'tool-request', - pushPayload: { type: 'tool-request', tool: 'fetch_x' }, + pushPayloads: [{ type: 'tool-request', tool: 'fetch_x' }], }; } return { decision: 'finish', - pushPayload: { type: 'finish', text: ctx.llmOutputText }, + pushPayloads: [{ type: 'finish', text: ctx.llmOutputText }], }; }, }); diff --git a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs index f121902..dcdfede 100644 --- a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs +++ b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs @@ -148,7 +148,7 @@ describe('hook path — ReasoningPush auto-emission', () => { // ctx.sessionId is exposed for exactly this purpose. onLLMOutput: (ctx) => ({ decision: 'finish', - pushPayload: { messageKind: 'content', message: ctx.llmOutputText, sessionId: ctx.sessionId }, + pushPayloads: [{ messageKind: 'content', message: ctx.llmOutputText, sessionId: ctx.sessionId }], }), }); const res = await handler(makeRequest(hookPayload({ sessionId: 'sess-pair-1' }))); @@ -178,7 +178,7 @@ describe('hook path — ReasoningPush auto-emission', () => { autoEmitReasoning: false, onLLMOutput: (ctx) => ({ decision: 'finish', - pushPayload: { messageKind: 'content', message: ctx.llmOutputText }, + pushPayloads: [{ messageKind: 'content', message: ctx.llmOutputText }], }), }); const res = await handler(makeRequest(hookPayload())); @@ -228,7 +228,7 @@ describe('hook path — ReasoningPush auto-emission', () => { } return { decision: 'finish', - pushPayload: { messageKind: 'content', message: ctx.llmOutputText, sessionId: ctx.sessionId }, + pushPayloads: [{ messageKind: 'content', message: ctx.llmOutputText, sessionId: ctx.sessionId }], }; }, }); From 11bbe0e55db9f7b3680270eaa837dee01bf1488a Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 15:55:12 +0800 Subject: [PATCH 15/33] test(amsg-instant)!: delete split-pattern suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit split-pattern-hook.test.mjs (1659 lines pinning lib-side hook split behaviour) is removed wholesale — the lib no longer splits anything in hook mode. handler.test.mjs's `splitPattern (0.6.0)` describe block and top-level `splitMessageIntoSentences` describe block are removed — the public split helper is gone (legacy path still uses it internally), and request-level splitPattern field rejection is covered by the new `next.4 — split-pattern fields removed` describe landed in Task 1. Also drops the `splitMessageIntoSentences` symbol from the test file's import (it was triggering a module-load failure since Task 3 un-exported it from message-processor.js). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/test/handler.test.mjs | 124 -- .../instant/test/split-pattern-hook.test.mjs | 1659 ----------------- 2 files changed, 1783 deletions(-) delete mode 100644 packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index fcb529d..ff4fa04 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -5,12 +5,6 @@ import { createInstantHandler, validateInstantPayload, } from '../src/index.js'; -// `splitMessageIntoSentences` lost its public re-export from src/index.js in -// next.4 (hook authors no longer get a split helper). The legacy test block -// below (`describe('splitMessageIntoSentences', ...)`) is slated for removal -// in Task 6 of the migration; meanwhile, pull the function from its internal -// home so this file still loads. -import { splitMessageIntoSentences } from '../src/message-processor.js'; import { generateTestVapid, generateTestSubscription, @@ -174,59 +168,6 @@ describe('validateInstantPayload', () => { assert.equal(validateInstantPayload(makeValidPayload({ temperature: 'hot' })).valid, false); }); - // ── splitPattern (0.6.0) ─────────────────────────────────────────── - it('accepts splitPattern as a single string', () => { - assert.equal(validateInstantPayload(makeValidPayload({ splitPattern: '([\\n]+)' })).valid, true); - }); - - it('accepts splitPattern as an array of strings (cascade)', () => { - assert.equal( - validateInstantPayload(makeValidPayload({ splitPattern: ['(\\n\\n+)', '([。!?!?]+)'] })).valid, - true - ); - }); - - it('treats empty splitPattern array as absent (uses default)', () => { - assert.equal(validateInstantPayload(makeValidPayload({ splitPattern: [] })).valid, true); - }); - - it('treats splitPattern=null as absent', () => { - assert.equal(validateInstantPayload(makeValidPayload({ splitPattern: null })).valid, true); - }); - - it('rejects splitPattern array element that is not a string', () => { - const r = validateInstantPayload(makeValidPayload({ splitPattern: ['ok', 123] })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /splitPattern\[1\]/); - }); - - it('rejects splitPattern item longer than 200 chars', () => { - const long = 'a'.repeat(201); - const r = validateInstantPayload(makeValidPayload({ splitPattern: long })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /200/); - }); - - it('rejects splitPattern array with more than 10 items', () => { - const r = validateInstantPayload(makeValidPayload({ - splitPattern: Array.from({ length: 11 }, () => '.'), - })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /10/); - }); - - it('rejects splitPattern that is not a valid RegExp source', () => { - const r = validateInstantPayload(makeValidPayload({ splitPattern: '[' })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /正则|RegExp|regex/i); - }); - - it('rejects splitPattern array element that is not a valid RegExp', () => { - const r = validateInstantPayload(makeValidPayload({ splitPattern: ['(\\n+)', '['] })); - assert.equal(r.valid, false); - assert.match(r.errorMessage, /splitPattern\[1\]/); - }); - // ── avatarUrl (0.6.1 → soft-strip in 0.7.1) ────────────────────────── it('accepts a normal https avatarUrl', () => { const payload = makeValidPayload({ avatarUrl: 'https://example.com/a.png' }); @@ -307,71 +248,6 @@ describe('next.4 — split-pattern fields removed', () => { }); }); -// ─── Unit: sentence splitting ───────────────────────────────────────── - -describe('splitMessageIntoSentences', () => { - it('splits on Chinese punctuation', () => { - const result = splitMessageIntoSentences('你好。今天天气真好!要带伞吗?'); - assert.deepEqual(result, ['你好。', '今天天气真好!', '要带伞吗?']); - }); - - it('splits on English ! and ? but NOT . (mirrors amsg-server regex)', () => { - const result = splitMessageIntoSentences('Hello. World! Done?'); - assert.deepEqual(result, ['Hello. World!', 'Done?']); - }); - - it('returns single-element array when no terminal punctuation', () => { - const result = splitMessageIntoSentences('no terminal punctuation here'); - assert.deepEqual(result, ['no terminal punctuation here']); - }); - - // ── splitPattern override (0.6.0) ────────────────────────────────── - it('accepts a single splitPattern string and uses it instead of default', () => { - const result = splitMessageIntoSentences('行一\n行二\n行三', '([\\n]+)'); - assert.deepEqual(result, ['行一\n', '行二\n', '行三']); - }); - - it('accepts splitPattern as a string[] cascade (paragraph → sentence)', () => { - const result = splitMessageIntoSentences( - '段一句一。段一句二。\n\n段二句一。', - ['(\\n\\n+)', '([。!?!?]+)'] - ); - // Cascade: - // 1. split by (\n\n+) → ['段一句一。段一句二。\n\n', '段二句一。'] - // 2. split each by ([。!?!?]+); the trailing \n\n in chunk 1 becomes an - // empty trimmed part and is dropped by the existing filter. - assert.deepEqual(result, ['段一句一。', '段一句二。', '段二句一。']); - }); - - it('uses default when splitPattern is null / undefined / []', () => { - const expected = ['你好。', '世界!']; - assert.deepEqual(splitMessageIntoSentences('你好。世界!', null), expected); - assert.deepEqual(splitMessageIntoSentences('你好。世界!', undefined), expected); - assert.deepEqual(splitMessageIntoSentences('你好。世界!', []), expected); - }); - - it('falls back to [original] when splitPattern matches everything', () => { - const result = splitMessageIntoSentences('abc', '.*'); - assert.deepEqual(result, ['abc']); - }); - - it('passes chunk through unchanged when cascade regex does not match', () => { - // First regex matches nothing → chunk passed as-is to second regex which splits. - const result = splitMessageIntoSentences('a.b.c', ['(z+)', '(\\.)']); - assert.deepEqual(result, ['a.', 'b.', 'c']); - }); - - it('without a capture group, splitter does NOT re-attach delimiter (documented behavior)', () => { - // No capture group → split() returns only text parts; reduce keeps even - // indices and `arr[i+1]` is the next text chunk, not a delimiter — so we - // see the documented quirk where every other chunk is concatenated. - const result = splitMessageIntoSentences('a.b.c', '\\.'); - // 'a.b.c'.split(/\./) === ['a','b','c']; reduce picks i=0 and i=2. - // i=0 → 'a' + arr[1] ('b') = 'ab'; i=2 → 'c' + undefined → 'c'. - assert.deepEqual(result, ['ab', 'c']); - }); -}); - // ─── Handler: request validation ─────────────────────────────────────── describe('createInstantHandler — request validation', () => { diff --git a/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs b/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs deleted file mode 100644 index 93cba23..0000000 --- a/packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs +++ /dev/null @@ -1,1659 +0,0 @@ -/** - * next.2 — splitPattern in hook mode. - * - * 0.7 / 0.8.0-next.1 disabled `splitPattern` on the hook path and - * emitted a startup warn whenever both `onLLMOutput` and `splitPattern` - * were set. This file pins the next.2 behaviour: - * - * - splitPattern applies to the kind-specific text field of the - * pushPayload returned by the hook (`content.message`, - * `reasoning.reasoningContent`, `tool_request.message`). - * - Default `/([。!?!?]+)/` mirrors the legacy path. - * - `null` / `[]` opt out. - * - Each chunk gets a fresh `messageId` + 1-based `messageIndex` + - * `totalMessages`, shares `sessionId`, copies `metadata` verbatim. - * - ToolRequestPush splitting demotes prefix chunks to ContentPush - * (drops `toolCalls`) and binds `toolCalls` to the chunk holding - * the LAST prefix segment (kept as `tool_request`). - * - Chunks are serialised with `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) - * spacing — same constant as the legacy path. - * - The "splitPattern is ignored" startup warn is gone. - * - Non-hook path is untouched (0.6 regression covered by - * handler.test.mjs; we re-assert one case here to be loud about - * it). - * - * Most tests drive `processInstantMessage` directly so we can inject a - * `sleep` mock — running real 1500 ms × (N-1) waits through the public - * handler would balloon the suite. The handler-level wire-up is covered - * by one end-to-end test that does pay the wall-clock cost. - */ - -import { describe, it, before } from 'node:test'; -import assert from 'node:assert/strict'; - -import { - createInstantHandler, - processInstantMessage, -} from '../src/index.js'; -import { - generateTestVapid, - generateTestSubscription, - createFetchRouter, - decryptCapturedPushBody, - makeLlmResponse, -} from './helpers.mjs'; - -const LLM_URL = 'https://api.example.com/v1/chat/completions'; - -let vapid; -let subKit; - -before(async () => { - vapid = await generateTestVapid(); - subKit = await generateTestSubscription(); -}); - -function basePayload(overrides = {}) { - return { - contactName: 'Rei', - messages: [{ role: 'user', content: 'kick the loop' }], - apiUrl: LLM_URL, - apiKey: 'sk-test', - primaryModel: 'model-x', - pushSubscription: subKit.subscription, - sessionId: 'sess-split', - ...overrides, - }; -} - -function makeRequest(url, body, headers = {}) { - return new Request(url, { - method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, - body: JSON.stringify(body), - }); -} - -/** Drive the processor directly with an instant-sleep mock + sleep tracker. */ -async function runProcessor(payload, hookCtxOverrides = {}) { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: hookCtxOverrides.llm || (() => makeLlmResponse('llm-output')), - }); - const sleeps = []; - const events = []; - const ctx = { - vapid, - fetch: router.fetch, - sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, - onEvent: (e) => events.push(e), - onLLMOutput: hookCtxOverrides.onLLMOutput, - requestUrl: 'http://localhost/instant', - autoEmitReasoning: hookCtxOverrides.autoEmitReasoning, - reasoningChunkBytes: hookCtxOverrides.reasoningChunkBytes, - blobStore: hookCtxOverrides.blobStore, - }; - const result = await processInstantMessage(payload, ctx); - const decoded = []; - for (const call of router.pushCalls) { - decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); - } - return { result, pushes: decoded, sleeps, events, router }; -} - -// ─── 1) hook + no splitPattern + default ContentPush ──────────────────── - -describe('hook mode + splitPattern — default sentence-split', () => { - it('splits content.message by default `/([。!?!?]+)/` into 3 pushes', async () => { - const { result, pushes, sleeps } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - metadata: { trace: 'xyz' }, - }, - }), - } - ); - assert.equal(result.status, 'finished'); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); - // wire format assertions - assert.equal(pushes[0].messageIndex, 1); - assert.equal(pushes[2].messageIndex, 3); - assert.equal(pushes[0].totalMessages, 3); - assert.equal(pushes[2].totalMessages, 3); - // shared sessionId, distinct messageIds, metadata copied - const sessionIds = new Set(pushes.map((p) => p.sessionId)); - assert.equal(sessionIds.size, 1); - const messageIds = new Set(pushes.map((p) => p.messageId)); - assert.equal(messageIds.size, 3); - assert.deepEqual(pushes.map((p) => p.metadata), [ - { trace: 'xyz' }, - { trace: 'xyz' }, - { trace: 'xyz' }, - ]); - // spacing: SLEEP_BETWEEN_MESSAGES_MS between every pair (N-1 sleeps) - assert.deepEqual(sleeps, [1500, 1500]); - }); -}); - -// ─── 2) explicit string splitPattern (same as default) ────────────────── - -describe('hook mode + splitPattern — explicit string', () => { - it('explicit `([。!?!?]+)` matches default behaviour', async () => { - const { pushes } = await runProcessor( - basePayload({ splitPattern: '([。!?!?]+)' }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); - }); -}); - -// ─── 3) array cascade ─────────────────────────────────────────────────── - -describe('hook mode + splitPattern — array cascade', () => { - it('splits by \\n+ first, then by sentence regex', async () => { - const { pushes } = await runProcessor( - basePayload({ splitPattern: ['\\n+', '([。!?!?]+)'] }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。\nC。D。', - }, - }), - } - ); - // First cascade: ['A。B。', 'C。D。'] (delimiter \n+ has no capture group → dropped) - // Second cascade: ['A。','B。','C。','D。'] - assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。', 'D。']); - assert.deepEqual(pushes.map((p) => p.messageIndex), [1, 2, 3, 4]); - assert.equal(pushes[0].totalMessages, 4); - }); -}); - -// ─── 4) splitPattern: null / [] disable splitting ─────────────────────── - -describe('hook mode + splitPattern — disable', () => { - it('splitPattern: null → single push (no split)', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload({ splitPattern: null }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].message, 'A。B。C。'); - assert.equal(pushes[0].messageId, 'hook-msg', 'single-chunk passthrough preserves original messageId'); - assert.deepEqual(sleeps, []); - }); - - it('splitPattern: [] → single push (no split)', async () => { - const { pushes } = await runProcessor( - basePayload({ splitPattern: [] }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].message, 'A。B。C。'); - }); -}); - -// ─── 5) no punctuation + default pattern → single push ────────────────── - -describe('hook mode + splitPattern — no match passes through', () => { - it('default regex on punctuation-free message → single push', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'no punctuation here at all', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].message, 'no punctuation here at all'); - assert.deepEqual(sleeps, []); - }); -}); - -// ─── 6) ReasoningPush — default off; opt-in via reasoningSplitPattern ─── - -describe('hook mode — ReasoningPush default off', () => { - it('reasoning is NOT split by default, even with sentence-laden content', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload(), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-reason-default', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'first thought。second thought。third。', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].reasoningContent, 'first thought。second thought。third。'); - assert.equal(pushes[0].messageId, 'hook-reason-default'); - assert.deepEqual(sleeps, []); - }); - - it('splitPattern alone does NOT split reasoning — reasoning has its own knob', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload({ splitPattern: '([。!?!?]+)' }), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-reason-default', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'first thought。second thought。third。', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.deepEqual(sleeps, []); - }); -}); - -describe('hook mode — reasoningSplitPattern enables reasoning splitting', () => { - it('reasoningSplitPattern: sentence regex → N reasoning pushes', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload({ reasoningSplitPattern: '([。!?!?]+)' }), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-reason', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'first thought。second thought。third。', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.messageKind), [ - 'reasoning', 'reasoning', 'reasoning', - ]); - assert.deepEqual(pushes.map((p) => p.reasoningContent), [ - 'first thought。', 'second thought。', 'third。', - ]); - assert.deepEqual(pushes.map((p) => p.messageIndex), [1, 2, 3]); - assert.deepEqual(pushes.map((p) => p.totalMessages), [3, 3, 3]); - assert.deepEqual(sleeps, [1500, 1500]); - }); - - it('reasoningSplitPattern: null / [] keep reasoning unsplit (explicit-off, same as undefined)', async () => { - for (const sp of [null, []]) { - const { pushes } = await runProcessor( - basePayload({ reasoningSplitPattern: sp }), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-reason', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'first thought。second thought。third。', - }, - }), - } - ); - assert.equal(pushes.length, 1, `reasoningSplitPattern: ${JSON.stringify(sp)}`); - } - }); - - it('reasoningSplitPattern cascade (string[]) is honoured', async () => { - const { pushes } = await runProcessor( - basePayload({ reasoningSplitPattern: ['\\n+', '([。!?!?]+)'] }), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-reason', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'A。B。\nC。D。', - }, - }), - } - ); - assert.deepEqual(pushes.map((p) => p.reasoningContent), ['A。', 'B。', 'C。', 'D。']); - }); -}); - -describe('hook mode — auto-emitted ReasoningPush also honours reasoningSplitPattern', () => { - it('framework-built reasoning from LLM splits when reasoningSplitPattern is set', async () => { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('final answer', { reasoning_content: 'step 1。step 2。step 3。' }), - }); - const sleeps = []; - const ctx = { - vapid, - fetch: router.fetch, - sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, - onLLMOutput: () => ({ decision: 'skip-push' }), - requestUrl: 'http://localhost/instant', - }; - await processInstantMessage( - basePayload({ reasoningSplitPattern: '([。!?!?]+)' }), - ctx - ); - const decoded = []; - for (const call of router.pushCalls) { - decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); - } - // 3 reasoning chunks, then hook skip-push → no content - assert.equal(decoded.length, 3); - assert.deepEqual(decoded.map((p) => p.messageKind), [ - 'reasoning', 'reasoning', 'reasoning', - ]); - assert.deepEqual(decoded.map((p) => p.reasoningContent), [ - 'step 1。', 'step 2。', 'step 3。', - ]); - // 2 sleeps between 3 chunks (auto-emit) — no post-burst sleep counted - // because the hook returned skip-push, but the legacy post-reasoning - // sleep before content burst still fires. - assert.deepEqual(sleeps.slice(0, 2), [1500, 1500]); - }); - - it('default (no reasoningSplitPattern) keeps auto-emit as a single reasoning push', async () => { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('final answer', { reasoning_content: 'step 1。step 2。step 3。' }), - }); - const ctx = { - vapid, - fetch: router.fetch, - sleep: () => Promise.resolve(), - onLLMOutput: () => ({ decision: 'skip-push' }), - requestUrl: 'http://localhost/instant', - }; - await processInstantMessage(basePayload(), ctx); - assert.equal(router.pushCalls.length, 1); - const decoded = JSON.parse(await decryptCapturedPushBody(router.pushCalls[0].body, subKit)); - assert.equal(decoded.messageKind, 'reasoning'); - assert.equal(decoded.reasoningContent, 'step 1。step 2。step 3。'); - }); -}); - -// ─── 7) ToolRequestPush — prefix chunks demote, toolCalls bind to last ── - -describe('hook mode + splitPattern — ToolRequestPush', () => { - it('N-1 chunks → ContentPush; final chunk → ToolRequestPush with toolCalls', async () => { - const toolCalls = [{ - id: 'call_1', - type: 'function', - function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' }, - }]; - const { pushes, sleeps } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'tool-request', - pushPayload: { - messageKind: 'tool_request', - messageType: 'instant', - source: 'instant', - messageId: 'hook-tool', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'Let me check。One moment。Fetching now。', - toolCalls, - }, - }), - } - ); - assert.equal(pushes.length, 3); - // Prefix chunks: ContentPush, no toolCalls - assert.equal(pushes[0].messageKind, 'content'); - assert.equal(pushes[1].messageKind, 'content'); - assert.equal('toolCalls' in pushes[0], false); - assert.equal('toolCalls' in pushes[1], false); - assert.equal(pushes[0].message, 'Let me check。'); - assert.equal(pushes[1].message, 'One moment。'); - // Final chunk keeps tool_request kind + full toolCalls atomic - assert.equal(pushes[2].messageKind, 'tool_request'); - assert.equal(pushes[2].message, 'Fetching now。'); - assert.deepEqual(pushes[2].toolCalls, toolCalls); - // All share sessionId - assert.equal(new Set(pushes.map((p) => p.sessionId)).size, 1); - // 1-based messageIndex on every chunk - assert.deepEqual(pushes.map((p) => p.messageIndex), [1, 2, 3]); - assert.deepEqual(pushes.map((p) => p.totalMessages), [3, 3, 3]); - assert.deepEqual(sleeps, [1500, 1500]); - }); - - it('single-segment ToolRequestPush passes through unchanged (toolCalls intact)', async () => { - const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; - const { pushes } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'tool-request', - pushPayload: { - messageKind: 'tool_request', - messageType: 'instant', - source: 'instant', - messageId: 'hook-tool-single', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'no punctuation', - toolCalls, - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].messageKind, 'tool_request'); - assert.deepEqual(pushes[0].toolCalls, toolCalls); - assert.equal(pushes[0].messageId, 'hook-tool-single'); - }); - - it('ToolRequestPush without `message` is not split (no field to slice)', async () => { - const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; - const { pushes } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'tool-request', - pushPayload: { - messageKind: 'tool_request', - messageType: 'instant', - source: 'instant', - messageId: 'hook-tool-no-msg', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - toolCalls, - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].messageKind, 'tool_request'); - assert.deepEqual(pushes[0].toolCalls, toolCalls); - }); -}); - -// ─── 8) ErrorPush — default off; opt-in via errorSplitPattern ─────────── - -describe('hook mode — ErrorPush default off', () => { - it('error kind passes through verbatim even with sentence-laden message', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'error', - messageType: 'instant', - source: 'instant', - messageId: 'hook-err', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - code: 'CUSTOM_FAIL', - message: 'first sentence。second sentence。', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].messageKind, 'error'); - assert.equal(pushes[0].message, 'first sentence。second sentence。'); - assert.deepEqual(sleeps, []); - }); - - it('splitPattern alone does NOT split error — error has its own knob', async () => { - const { pushes } = await runProcessor( - basePayload({ splitPattern: '([。!?!?]+)' }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'error', - messageType: 'instant', - source: 'instant', - messageId: 'hook-err', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - code: 'CUSTOM_FAIL', - message: 'first sentence。second sentence。', - }, - }), - } - ); - assert.equal(pushes.length, 1); - }); -}); - -describe('hook mode — errorSplitPattern enables error splitting', () => { - it('errorSplitPattern: sentence regex → N error pushes', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload({ errorSplitPattern: '([。!?!?]+)' }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'error', - messageType: 'instant', - source: 'instant', - messageId: 'hook-err', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - code: 'CUSTOM_FAIL', - message: 'first sentence。second sentence。third sentence。', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.messageKind), ['error', 'error', 'error']); - assert.deepEqual(pushes.map((p) => p.message), [ - 'first sentence。', 'second sentence。', 'third sentence。', - ]); - // `code` and other top-level fields are preserved on every chunk. - assert.deepEqual(pushes.map((p) => p.code), [ - 'CUSTOM_FAIL', 'CUSTOM_FAIL', 'CUSTOM_FAIL', - ]); - assert.deepEqual(sleeps, [1500, 1500]); - }); - - it('errorSplitPattern: null / [] keep error unsplit (explicit-off)', async () => { - for (const sp of [null, []]) { - const { pushes } = await runProcessor( - basePayload({ errorSplitPattern: sp }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'error', - messageType: 'instant', - source: 'instant', - messageId: 'hook-err', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - code: 'CUSTOM_FAIL', - message: 'first sentence。second sentence。', - }, - }), - } - ); - assert.equal(pushes.length, 1, `errorSplitPattern: ${JSON.stringify(sp)}`); - } - }); -}); - -describe('hook mode — LOOP_EXCEEDED diagnostic respects errorSplitPattern', () => { - it('framework-built LOOP_EXCEEDED can be chunked when errorSplitPattern matches', async () => { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('again'), - }); - const sleeps = []; - const ctx = { - vapid, - fetch: router.fetch, - sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, - maxLoopIterations: 2, - onLLMOutput: (sctx) => ({ decision: 'continue', nextHistory: sctx.messages }), - requestUrl: 'http://localhost/instant', - }; - // The framework message is "Agentic loop exceeded 2 iterations" — - // no sentence punctuation. Split by whitespace so we can prove the - // path runs through the splitter for ErrorPush too. - const result = await processInstantMessage( - basePayload({ errorSplitPattern: '(\\s+)' }), - ctx - ); - assert.equal(result.status, 'loop_exceeded'); - // "Agentic loop exceeded 2 iterations" → 4 tokens with the - // capture-group-spaces convention. Just check >1 to keep the - // assertion robust against future wording tweaks. - assert.ok(router.pushCalls.length >= 2, `expected ≥2 chunks, got ${router.pushCalls.length}`); - const decoded = []; - for (const call of router.pushCalls) { - decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); - } - assert.equal(decoded[0].messageKind, 'error'); - assert.equal(decoded[0].code, 'LOOP_EXCEEDED'); - }); -}); - -// ─── 9) Free-form pushPayload is never split ──────────────────────────── - -describe('hook mode + splitPattern — free-form payload opts out', () => { - it('payload without `messageKind` passes through verbatim', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload(), - { - onLLMOutput: () => ({ - decision: 'finish', - // No messageKind → framework can't guess which field to split. - pushPayload: { type: 'legacy', text: 'A。B。C。' }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].text, 'A。B。C。'); - assert.equal(pushes[0].type, 'legacy'); - assert.deepEqual(sleeps, []); - }); -}); - -// ─── 10) ordering: pushes ship strictly serially with the sleep gap ───── - -describe('hook mode + splitPattern — serial ordering', () => { - it('emits pushes in 1..N order interleaved with sleeps', async () => { - const sequence = []; - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('x'), - onPush: () => { sequence.push('push'); return undefined; }, - }); - const ctx = { - vapid, - fetch: router.fetch, - sleep: (ms) => { sequence.push(`sleep:${ms}`); return Promise.resolve(); }, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'hook-msg', - sessionId: sctx.sessionId, - message: 'A。B。C。', - }, - }), - requestUrl: 'http://localhost/instant', - }; - await processInstantMessage(basePayload(), ctx); - assert.deepEqual(sequence, [ - 'push', 'sleep:1500', - 'push', 'sleep:1500', - 'push', - ]); - }); -}); - -// ─── 10b) per-push splitPattern override on pushPayload (next.3+) ─────── -// -// 0.8.0-next.2 treated `splitPattern` strictly as a request-level field -// — a hook that wrote `splitPattern: null` on its own pushPayload had -// the field silently ignored (the only way to disable splitting for one -// push was to flip the outer request body). next.3 promotes -// `pushPayload.splitPattern` to a per-push override that takes -// precedence over the request-level field for that one push, and gets -// stripped before delivery so it never leaks onto the wire. - -describe('hook mode + splitPattern — per-push override', () => { - it('pushPayload.splitPattern: null disables split even when outer request is default-on', async () => { - const { pushes, sleeps } = await runProcessor( - basePayload(), // outer request: splitPattern undefined → default sentence-split on - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'override-null', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: null, - }, - }), - } - ); - assert.equal(pushes.length, 1, 'override null → single push'); - assert.equal(pushes[0].message, 'A。B。C。'); - assert.equal(pushes[0].messageId, 'override-null'); - assert.deepEqual(sleeps, []); - // Stripped before delivery — never appears on the wire. - assert.equal('splitPattern' in pushes[0], false); - }); - - it('pushPayload.splitPattern beats outer request splitPattern (override > request)', async () => { - // Outer says split-by-newline, push override says split-by-sentence. - // Override should win → 3 chunks on `。`, not 1. - const { pushes } = await runProcessor( - basePayload({ splitPattern: '(\\n+)' }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'override-string', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: '([。!?!?]+)', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); - // Stripped from every chunk, not just one. - for (const p of pushes) assert.equal('splitPattern' in p, false); - }); - - it('pushPayload.splitPattern: [] disables (same `null`-or-empty rule as request-level)', async () => { - const { pushes } = await runProcessor( - basePayload({ splitPattern: '([。!?!?]+)' }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'override-empty', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: [], - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].message, 'A。B。C。'); - assert.equal('splitPattern' in pushes[0], false); - }); - - it('pushPayload.splitPattern enables split on a default-off kind (reasoning)', async () => { - // Reasoning is default-off at request level. Hook puts splitPattern - // on the ReasoningPush → that one push splits even though the - // request omitted `reasoningSplitPattern`. - const { pushes, sleeps } = await runProcessor( - basePayload(), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'reason-override', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'first。second。third。', - splitPattern: '([。!?!?]+)', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.messageKind), ['reasoning', 'reasoning', 'reasoning']); - assert.deepEqual(pushes.map((p) => p.reasoningContent), ['first。', 'second。', 'third。']); - assert.deepEqual(sleeps, [1500, 1500]); - for (const p of pushes) assert.equal('splitPattern' in p, false); - }); - - it('pushPayload.splitPattern enables split on a default-off kind (error)', async () => { - const { pushes } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'error', - messageType: 'instant', - source: 'instant', - messageId: 'err-override', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - code: 'CUSTOM_FAIL', - message: 'first。second。third。', - splitPattern: '([。!?!?]+)', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.messageKind), ['error', 'error', 'error']); - assert.deepEqual(pushes.map((p) => p.code), ['CUSTOM_FAIL', 'CUSTOM_FAIL', 'CUSTOM_FAIL']); - for (const p of pushes) assert.equal('splitPattern' in p, false); - }); - - it('absent pushPayload.splitPattern falls through to outer request (existing behaviour)', async () => { - // No `splitPattern` field on the push → outer request controls. - const { pushes } = await runProcessor( - basePayload({ splitPattern: null }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'no-override', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - }, - }), - } - ); - assert.equal(pushes.length, 1, 'outer null disables → single push'); - assert.equal(pushes[0].message, 'A。B。C。'); - }); - - it('malformed pushPayload.splitPattern surfaces as HookError', async () => { - // Unbalanced regex group — same shape rule as request-level. - await assert.rejects( - runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'bad-override', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: '(', - }, - }), - } - ), - (err) => { - assert.equal(err.name, 'HookError'); - assert.ok(/pushPayload\.splitPattern invalid/.test(err.message)); - return true; - } - ); - }); - - it('ToolRequestPush override demotes prefix chunks + binds toolCalls to last (same as request-level)', async () => { - const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; - const { pushes } = await runProcessor( - basePayload({ splitPattern: null }), // outer says off — override re-enables for this push only - { - onLLMOutput: (sctx) => ({ - decision: 'tool-request', - pushPayload: { - messageKind: 'tool_request', - messageType: 'instant', - source: 'instant', - messageId: 'tool-override', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'one。two。three。', - toolCalls, - splitPattern: '([。!?!?]+)', - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.messageKind), ['content', 'content', 'tool_request']); - assert.equal('toolCalls' in pushes[0], false); - assert.equal('toolCalls' in pushes[1], false); - assert.deepEqual(pushes[2].toolCalls, toolCalls); - // Override field is stripped from every chunk, including the - // demoted ContentPush chunks (which spread from cleanPushObj). - for (const p of pushes) assert.equal('splitPattern' in p, false); - }); - - it('splitPattern stripped even when override fires but produces a single segment', async () => { - // Punctuation-free message + a regex that won't match → single - // chunk passthrough. The strip should still apply. - const { pushes } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'no-match-strip', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'no punctuation here', - splitPattern: '([。!?!?]+)', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0].messageId, 'no-match-strip', 'no-match passthrough still preserves messageId'); - assert.equal('splitPattern' in pushes[0], false, 'no-match passthrough still strips override'); - }); - - // ─── undefined vs null distinction ────────────────────────────────── - // - // `null` is an *opinion* ("explicitly off for this push, ignore the - // request-level field"). `undefined` is *not an opinion* ("I didn't - // set this, do whatever the request-level field says"). Matches the - // request-level convention and plain-JS reading of `undefined`. - - it('splitPattern: undefined is treated as absent — falls back to outer request', async () => { - // Outer says default-on. Push has the field set to `undefined`. - // Should behave the same as if the field were absent: split. - const { pushes } = await runProcessor( - basePayload(), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'undef-fallback', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: undefined, - }, - }), - } - ); - assert.equal(pushes.length, 3, 'undefined override must NOT shadow outer default-on'); - assert.deepEqual(pushes.map((p) => p.message), ['A。', 'B。', 'C。']); - for (const p of pushes) assert.equal('splitPattern' in p, false); - }); - - it('splitPattern: undefined + outer null → respects outer null (still falls back)', async () => { - const { pushes } = await runProcessor( - basePayload({ splitPattern: null }), - { - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'undef-outer-null', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: undefined, - }, - }), - } - ); - assert.equal(pushes.length, 1, 'undefined must fall back to outer null → unsplit'); - assert.equal(pushes[0].message, 'A。B。C。'); - }); - - // ─── non-recursive split: demoted chunks aren't re-split ──────────── - // - // `splitHookPushPayload` runs exactly once per push delivery. The - // ToolRequestPush prefix-demotion path produces ContentPush chunks - // that share the same `cleanPushObj` (already-stripped) parent — so - // there's no second pass that could see the override and re-split. - // This test wires up a scenario that would catch any future - // recursion: a 5-segment override that, if applied twice, would - // shatter into 25+ chunks. Asserting exact count == 5 pins the - // single-pass invariant. - - it('strip is clone-based — does NOT mutate the original pushPayload', async () => { - // Hook authors may legitimately return a cached / shared - // pushPayload template (e.g. a frozen base + per-iteration - // overrides). If the library used `delete` on the original - // object, the second reuse of the same reference would silently - // lose its `splitPattern`. Assert clone-based strip by inspecting - // the original object after delivery. - const shared = { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'shared-template', - sessionId: 'sess-clone', - timestamp: '2026-01-01T00:00:00.000Z', - message: 'A。B。C。', - splitPattern: null, // explicit-off - }; - const { pushes } = await runProcessor( - basePayload(), - { - onLLMOutput: () => ({ decision: 'finish', pushPayload: shared }), - } - ); - assert.equal(pushes.length, 1, 'override null disables → single push'); - // Wire-clean. - for (const p of pushes) assert.equal('splitPattern' in p, false); - // Original object untouched — hook author's template is safe to - // reuse on the next iteration / next request. - assert.equal('splitPattern' in shared, true, 'original pushPayload must NOT be mutated'); - assert.equal(shared.splitPattern, null, 'original splitPattern value preserved'); - assert.equal(shared.messageId, 'shared-template', 'other fields untouched'); - }); - - it('override on ToolRequestPush splits once — demoted ContentPush chunks not re-split', async () => { - const toolCalls = [{ id: 'c1', type: 'function', function: { name: 'x' } }]; - const { pushes } = await runProcessor( - basePayload({ splitPattern: null }), // outer off, override on - { - onLLMOutput: (sctx) => ({ - decision: 'tool-request', - pushPayload: { - messageKind: 'tool_request', - messageType: 'instant', - source: 'instant', - messageId: 'recursion-check', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - message: 'a。b。c。d。e。', // 5 sentences → 5 chunks if single-pass - toolCalls, - splitPattern: '([。!?!?]+)', - }, - }), - } - ); - assert.equal(pushes.length, 5, 'must be exactly 5 — recursion would inflate this'); - assert.deepEqual(pushes.map((p) => p.messageKind), [ - 'content', 'content', 'content', 'content', 'tool_request', - ]); - assert.deepEqual(pushes.map((p) => p.message), ['a。', 'b。', 'c。', 'd。', 'e。']); - // Demoted ContentPush prefix chunks (chunks 0..3) carry no - // toolCalls and no splitPattern. Final tool_request chunk still - // has toolCalls but no splitPattern. - for (let i = 0; i < 4; i++) { - assert.equal('toolCalls' in pushes[i], false, `chunk ${i}: no toolCalls`); - assert.equal('splitPattern' in pushes[i], false, `chunk ${i}: no splitPattern`); - } - assert.deepEqual(pushes[4].toolCalls, toolCalls); - assert.equal('splitPattern' in pushes[4], false, 'final chunk: no splitPattern'); - }); -}); - -// ─── 11) no startup warn about splitPattern + onLLMOutput combo ───────── - -describe('createInstantHandler — no warn about splitPattern in hook mode', () => { - it('does NOT emit "splitPattern is ignored" warning anymore', () => { - const warnings = []; - const origWarn = console.warn; - console.warn = (...args) => { warnings.push(args.join(' ')); }; - try { - createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - }); - // Construct a second handler that explicitly passes splitPattern - // via the request payload path — there's no handler-level option, - // so the only thing that could have warned was the old 0.7 block. - createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - }); - } finally { - console.warn = origWarn; - } - const offending = warnings.find((w) => w.includes('splitPattern is ignored')); - assert.equal(offending, undefined, `unexpected warn: ${offending}`); - }); -}); - -// ─── 12) legacy path regression — splitPattern still works without hook ─ - -describe('non-hook regression — splitPattern still drives sentence-burst', () => { - it('legacy path with default splitPattern still emits N content pushes', async () => { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('A。B。C。'), - }); - const sleeps = []; - const ctx = { - vapid, - fetch: router.fetch, - sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, - // No onLLMOutput → legacy path. - requestUrl: 'http://localhost/instant', - }; - const payload = { - contactName: 'Rei', - completePrompt: 'say A B C', - apiUrl: LLM_URL, - apiKey: 'sk-test', - primaryModel: 'model-x', - pushSubscription: subKit.subscription, - }; - const result = await processInstantMessage(payload, ctx); - assert.equal(result.messagesSent, 3); - assert.equal(router.pushCalls.length, 3); - const decoded = []; - for (const call of router.pushCalls) { - decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); - } - assert.deepEqual(decoded.map((p) => p.message), ['A。', 'B。', 'C。']); - assert.deepEqual(sleeps, [1500, 1500]); - }); -}); - -// ─── validation: per-kind split-pattern fields ────────────────────────── - -describe('validation — reasoningSplitPattern + errorSplitPattern', () => { - it('rejects reasoningSplitPattern that is not string / string[]', async () => { - const handler = createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - }); - const res = await handler(makeRequest( - 'http://h/instant', - basePayload({ reasoningSplitPattern: 42 }) - )); - assert.equal(res.status, 400); - const body = await res.json(); - assert.equal(body.error.code, 'INVALID_PAYLOAD_FORMAT'); - assert.deepEqual(body.error.details.invalidFields, ['reasoningSplitPattern']); - assert.ok(body.error.message.includes('reasoningSplitPattern')); - }); - - it('rejects errorSplitPattern with un-compilable regex', async () => { - const handler = createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - }); - const res = await handler(makeRequest( - 'http://h/instant', - basePayload({ errorSplitPattern: '(' /* unbalanced group */ }) - )); - assert.equal(res.status, 400); - const body = await res.json(); - assert.deepEqual(body.error.details.invalidFields, ['errorSplitPattern']); - }); - - it('accepts valid string / string[] / null / [] / undefined', async () => { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('x'), - }); - const handler = createInstantHandler({ - vapid, - fetch: router.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - }); - for (const sp of [undefined, null, [], '([。!?!?]+)', ['\\n+', '([。!?!?]+)']]) { - const res = await handler(makeRequest( - 'http://h/instant', - basePayload({ reasoningSplitPattern: sp, errorSplitPattern: sp }) - )); - assert.equal(res.status, 200, `reasoningSplitPattern: ${JSON.stringify(sp)}`); - } - }); -}); - -// ─── 13) handler-level wiring smoke test ──────────────────────────────── -// -// Spends one real 1500 ms sleep through `setTimeout` to prove the -// public handler entry-point honours splitPattern end-to-end. Only one -// chunk-gap so the suite cost is bounded. - -describe('handler entry-point — wires through splitPattern end-to-end', () => { - it('createInstantHandler + hook + 2-chunk message → 2 pushes', async () => { - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('x'), - }); - const handler = createInstantHandler({ - vapid, - fetch: router.fetch, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'content', - messageType: 'instant', - source: 'instant', - messageId: 'wire-msg', - sessionId: sctx.sessionId, - message: 'first half。second half。', - }, - }), - }); - const start = Date.now(); - const res = await handler(makeRequest('http://h/instant', basePayload())); - const elapsed = Date.now() - start; - assert.equal(res.status, 200); - assert.equal(router.pushCalls.length, 2); - // Real 1.5s gap between two pushes. Allow generous slack (CI). - assert.ok(elapsed >= 1400, `expected ≥1.4s wall, got ${elapsed}ms`); - }); -}); - -// ─── reasoning byte chunking (next.2 Layer 2) ─────────────────────────── - -describe('reasoning byte chunking — defaults', () => { - it('short reasoning (< 2000 B) ships as a single push, no chunkIndex fields', async () => { - const { pushes } = await runProcessor( - basePayload(), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-small', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: 'short thought', - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.ok(!('chunkIndex' in pushes[0])); - assert.ok(!('totalChunks' in pushes[0])); - // Single-chunk passthrough preserves original messageId (no regen). - assert.equal(pushes[0].messageId, 'hook-small'); - }); - - it('6 KB ASCII reasoning at default 2000 B → 3 chunks with chunkIndex 1..3', async () => { - const big = 'a'.repeat(6000); - const { pushes, sleeps, events } = await runProcessor( - basePayload(), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-big', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: big, - }, - }), - } - ); - assert.equal(pushes.length, 3); - assert.deepEqual(pushes.map((p) => p.messageKind), ['reasoning', 'reasoning', 'reasoning']); - assert.deepEqual(pushes.map((p) => p.chunkIndex), [1, 2, 3]); - assert.deepEqual(pushes.map((p) => p.totalChunks), [3, 3, 3]); - // Reassembled content must equal the input — no data loss. - assert.equal(pushes.map((p) => p.reasoningContent).join(''), big); - // Each chunk's reasoningContent UTF-8 bytes ≤ threshold. - const enc = new TextEncoder(); - for (const p of pushes) assert.ok(enc.encode(p.reasoningContent).byteLength <= 2000); - // 100ms gap between Layer-2 chunks of the same Layer-1 segment. - assert.deepEqual(sleeps, [100, 100]); - // One `reasoning_chunked` event fired. - const chunkedEvents = events.filter((e) => e.type === 'reasoning_chunked'); - assert.equal(chunkedEvents.length, 1); - assert.equal(chunkedEvents[0].totalChunks, 3); - assert.equal(chunkedEvents[0].totalBytes, 6000); - assert.equal(chunkedEvents[0].sessionId, 'sess-split'); - }); - - it('CJK 1500-char reasoning (~4500 B) at default threshold → safe codepoint boundaries', async () => { - const cjk = '寿'.repeat(1500); - const { pushes } = await runProcessor( - basePayload(), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-cjk', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: cjk, - }, - }), - } - ); - assert.ok(pushes.length >= 3); - assert.equal(pushes.map((p) => p.reasoningContent).join(''), cjk); - // Every chunk decodes cleanly — no garbled half-character residue. - for (const p of pushes) { - assert.ok(typeof p.reasoningContent === 'string' && p.reasoningContent.length > 0); - } - }); - - it('emoji (4-byte char) reasoning chunks at codepoint boundary', async () => { - const text = '🙂'.repeat(800); // 800 × 4 = 3200 B → ≥2 chunks at 2000 B threshold - const { pushes } = await runProcessor( - basePayload(), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-emoji', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: text, - }, - }), - } - ); - assert.ok(pushes.length >= 2); - assert.equal(pushes.map((p) => p.reasoningContent).join(''), text); - }); -}); - -describe('reasoning byte chunking — cascade with reasoningSplitPattern', () => { - it('sentence-split first, oversized sentences then byte-chunked', async () => { - // Three sentences. Sentence 2 is 5 KB → should byte-chunk into 3. - // Sentences 1 and 3 are short → stay single. - const big = 'b'.repeat(5000); - const text = `start。${big}。end。`; - const { pushes, sleeps } = await runProcessor( - basePayload({ reasoningSplitPattern: '([。!?!?]+)' }), - { - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-cascade', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: text, - }, - }), - } - ); - // Layer 1 produces 3 segments. Segment 2 is oversized → byte-chunks - // into 3. Final leaf count: 1 + 3 + 1 = 5. - assert.equal(pushes.length, 5); - - // First leaf: Layer 1 segment 1, no byte chunking. - assert.equal(pushes[0].messageIndex, 1); - assert.equal(pushes[0].totalMessages, 3); - assert.ok(!('chunkIndex' in pushes[0])); - assert.equal(pushes[0].reasoningContent, 'start。'); - - // Leaves 2..4: Layer 1 segment 2, byte-chunked into 3. - for (let i = 1; i <= 3; i++) { - assert.equal(pushes[i].messageIndex, 2); - assert.equal(pushes[i].totalMessages, 3); - assert.equal(pushes[i].chunkIndex, i); - assert.equal(pushes[i].totalChunks, 3); - } - // Byte-chunks concat back to the original Layer-1 segment 2. - assert.equal(pushes.slice(1, 4).map((p) => p.reasoningContent).join(''), big + '。'); - - // Last leaf: Layer 1 segment 3, no byte chunking. - assert.equal(pushes[4].messageIndex, 3); - assert.ok(!('chunkIndex' in pushes[4])); - assert.equal(pushes[4].reasoningContent, 'end。'); - - // Sleeps: 1500ms between Layer-1 segments, 100ms between Layer-2 chunks of segment 2. - // Sequence: send leaf 0 → 1500 (boundary) → leaf 1 → 100 → leaf 2 → 100 → leaf 3 → 1500 (boundary) → leaf 4. - assert.deepEqual(sleeps, [1500, 100, 100, 1500]); - }); -}); - -describe('reasoning byte chunking — disable knob', () => { - it('reasoningChunkBytes: null + big reasoning + no BlobStore → PAYLOAD_TOO_LARGE', async () => { - const big = 'a'.repeat(6000); - await assert.rejects( - runProcessor( - basePayload(), - { - reasoningChunkBytes: null, - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-big', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: big, - }, - }), - } - ), - (err) => err && err.code === 'PAYLOAD_TOO_LARGE' - ); - }); - - it('reasoningChunkBytes: null + big reasoning + BlobStore configured → 1 envelope push', async () => { - const { createMemoryBlobStore } = await import('../src/blob-store/memory.js'); - const blobAdapter = createMemoryBlobStore(); - const big = 'a'.repeat(6000); - const { pushes } = await runProcessor( - basePayload(), - { - reasoningChunkBytes: null, - blobStore: { adapter: blobAdapter }, - autoEmitReasoning: false, - onLLMOutput: (sctx) => ({ - decision: 'finish', - pushPayload: { - messageKind: 'reasoning', - messageType: 'instant', - source: 'instant', - messageId: 'hook-big', - sessionId: sctx.sessionId, - timestamp: '2026-01-01T00:00:00.000Z', - reasoningContent: big, - }, - }), - } - ); - assert.equal(pushes.length, 1); - assert.equal(pushes[0]._blob, true); - assert.equal(pushes[0].messageKind, 'reasoning'); - }); -}); - -describe('reasoning byte chunking — legacy path (no onLLMOutput)', () => { - it('legacy reasoning auto-emit chunks by bytes too', async () => { - const big = 'a'.repeat(6000); - const router = createFetchRouter({ - pushEndpoint: subKit.subscription.endpoint, - llm: () => makeLlmResponse('final', { reasoning_content: big }), - }); - const sleeps = []; - const events = []; - const ctx = { - vapid, - fetch: router.fetch, - sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, - onEvent: (e) => events.push(e), - requestUrl: 'http://localhost/instant', - // No onLLMOutput → legacy path. - }; - const payload = { - contactName: 'Rei', - completePrompt: 'reason a lot', - apiUrl: LLM_URL, - apiKey: 'sk-test', - primaryModel: 'model-x', - pushSubscription: subKit.subscription, - }; - await processInstantMessage(payload, ctx); - const decoded = []; - for (const call of router.pushCalls) { - decoded.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); - } - const reasonings = decoded.filter((p) => p.messageKind === 'reasoning'); - // 6000 B reasoning → 3 chunks of ≤ 2000 B each. - assert.equal(reasonings.length, 3); - assert.deepEqual(reasonings.map((p) => p.chunkIndex), [1, 2, 3]); - assert.equal(reasonings.map((p) => p.reasoningContent).join(''), big); - // The `reasoning_chunked` event fired on the legacy path too (no iteration). - const chunkedEvents = events.filter((e) => e.type === 'reasoning_chunked'); - assert.equal(chunkedEvents.length, 1); - assert.ok(!('iteration' in chunkedEvents[0])); - }); -}); - -describe('reasoning byte chunking — handler-level validation', () => { - it('throws TypeError when reasoningChunkBytes is 0 / negative / non-integer / too big', async () => { - for (const bad of [0, -1, 1.5, NaN, 'big', {}, []]) { - assert.throws( - () => createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - reasoningChunkBytes: bad, - }), - TypeError, - `expected TypeError for reasoningChunkBytes=${JSON.stringify(bad)}` - ); - } - }); - - it('throws when reasoningChunkBytes exceeds maxInlineBytes - 600 margin', async () => { - // Default maxInlineBytes = 2600 → upper bound = 2000. 2001 should throw. - assert.throws( - () => createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - reasoningChunkBytes: 2001, - }), - TypeError, - ); - }); - - it('accepts undefined (default 2000), null (disable), and in-range positive integer', async () => { - for (const v of [undefined, null, 500, 1000, 2000]) { - assert.doesNotThrow( - () => createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - reasoningChunkBytes: v, - }), - `should accept reasoningChunkBytes=${JSON.stringify(v)}`, - ); - } - }); - - it('uses blobStore.maxInlineBytes to compute the upper bound', async () => { - // Custom blobStore with maxInlineBytes 4096 → upper bound = 3496. - const customBlob = { adapter: { put: async () => {}, read: async () => null }, maxInlineBytes: 4096 }; - // 3000 should be accepted with the wider cap (it'd be over the default 2000 cap). - assert.doesNotThrow( - () => createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - blobStore: customBlob, - reasoningChunkBytes: 3000, - }), - ); - // 3497 still over the upper bound. - assert.throws( - () => createInstantHandler({ - vapid, - fetch: globalThis.fetch, - onLLMOutput: () => ({ decision: 'skip-push' }), - blobStore: customBlob, - reasoningChunkBytes: 3497, - }), - TypeError, - ); - }); -}); From e2d724277e437f92780dd798e080273670f614c6 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 16:00:18 +0800 Subject: [PATCH 16/33] test(amsg-instant): 13-case pushPayloads contract matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the next.4 hook contract per spec §测试要求: single push, multi-push spacing, mid-array throw, HookError rejection cases, kind/decision decoupling, messageId precedence, index auto-fill, and reasoning auto-emit interplay. Most cases overlap with Task-2/Task-3 tests in agentic-loop.test.mjs, but this file is the dedicated, auditable contract document. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/test/pushpayloads-array.test.mjs | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs diff --git a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs new file mode 100644 index 0000000..90d1db0 --- /dev/null +++ b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs @@ -0,0 +1,316 @@ +/** + * next.4 — pushPayloads-only hook decision API contract matrix. + * + * Pins the 13 fixtures from spec §测试要求. + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createInstantHandler, + processInstantMessage, +} from '../src/index.js'; +import { + generateTestVapid, + generateTestSubscription, + createFetchRouter, + decryptCapturedPushBody, + makeLlmResponse, +} from './helpers.mjs'; + +const LLM_URL = 'https://api.example.com/v1/chat/completions'; +let vapid, subKit; +before(async () => { vapid = await generateTestVapid(); subKit = await generateTestSubscription(); }); + +function basePayload(overrides = {}) { + return { + contactName: 'Rei', + messages: [{ role: 'user', content: 'kick the loop' }], + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + sessionId: 'sess-fixture', + ...overrides, + }; +} + +function makeRequest(url, body, headers = {}) { + return new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +async function runDirect(hookReturn, ctxOverrides = {}) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('llm-output', ctxOverrides.reasoning ? { reasoning_content: ctxOverrides.reasoning } : undefined), + }); + const sleeps = []; + const events = []; + const result = await processInstantMessage(basePayload(ctxOverrides.payload), { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onEvent: (e) => events.push(e), + onLLMOutput: () => hookReturn, + autoEmitReasoning: ctxOverrides.autoEmitReasoning, + reasoningChunkBytes: ctxOverrides.reasoningChunkBytes, + blobStore: ctxOverrides.blobStore, + requestUrl: 'http://localhost/instant', + }); + const decoded = []; + for (const c of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(c.body, subKit))); + } + return { result, pushes: decoded, sleeps, events, router }; +} + +async function runHandler(hookReturn, ctxOverrides = {}) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('llm-output'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + autoEmitReasoning: false, + onLLMOutput: () => hookReturn, + }); + const res = await handler(makeRequest('http://localhost/instant', basePayload(ctxOverrides.payload))); + return { res, body: await res.json(), router }; +} + +// 1) Single-push happy path +describe('1) pushPayloads.length === 1', () => { + it('single push goes through, messageIndex=1, totalMessages=1, metadata preserved', async () => { + const { result, pushes, sleeps } = await runDirect({ + decision: 'finish', + pushPayloads: [{ + messageKind: 'content', + message: 'hi', + metadata: { trace: 'x' }, + notification: { title: 'Rei', body: 'hi' }, + }], + }, { autoEmitReasoning: false }); + assert.equal(result.status, 'finished'); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'hi'); + assert.equal(pushes[0].messageIndex, 1); + assert.equal(pushes[0].totalMessages, 1); + assert.deepEqual(pushes[0].metadata, { trace: 'x' }); + assert.deepEqual(pushes[0].notification, { title: 'Rei', body: 'hi' }); + assert.deepEqual(sleeps, []); + }); +}); + +// 2) Three-push multi-burst with 1500ms spacing +describe('2) pushPayloads.length === 3', () => { + it('ships 3 pushes with correct indices + 1500ms spacing', async () => { + const { pushes, sleeps } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a' }, + { messageKind: 'content', message: 'b' }, + { messageKind: 'content', message: 'c' }, + ], + }, { autoEmitReasoning: false }); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map(p => p.message), ['a', 'b', 'c']); + assert.deepEqual(pushes.map(p => p.messageIndex), [1, 2, 3]); + assert.deepEqual(pushes.map(p => p.totalMessages), [3, 3, 3]); + assert.deepEqual(sleeps, [1500, 1500]); + }); +}); + +// 3) Mid-array throw +describe('3) mid-array throw aborts remaining + no final_pushed', () => { + it('push 2 fails → push 3 never sent, push_failed propagates', async () => { + let pushIdx = 0; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + pushHandler: () => { + pushIdx++; + if (pushIdx === 2) return { ok: false, status: 502, statusText: 'BG', async text() { return ''; } }; + return { ok: true, status: 201, async text() { return ''; } }; + }, + }); + const events = []; + let caught; + try { + await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onEvent: (e) => events.push(e), + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'one' }, + { messageKind: 'content', message: 'two' }, + { messageKind: 'content', message: 'three' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + } catch (e) { caught = e; } + assert.ok(caught); + assert.equal(pushIdx, 2); + assert.equal(events.some(e => e.type === 'final_pushed'), false); + }); +}); + +// 4) Empty array → HookError +describe('4) pushPayloads: [] → HookError', () => { + it('empty array routed to skip-push hint via HOOK_THREW', async () => { + const { res, body } = await runHandler({ decision: 'finish', pushPayloads: [] }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /use decision: skip-push to skip notification entirely/); + }); +}); + +// 5) BOTH pushPayload + pushPayloads → HookError +describe('5) pushPayload + pushPayloads → HookError', () => { + it('mixing singular and plural keys is rejected', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'a' }, + pushPayloads: [{ messageKind: 'content', message: 'b' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /use pushPayloads/); + }); +}); + +// 6) ONLY pushPayload (singular) → HookError with migration hint +describe('6) only pushPayload (singular) → HookError', () => { + it('migration message tells the caller to wrap in an array', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'a' }, + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /pushPayloads: \[yourPayload\]/); + }); +}); + +// 7) push.splitPattern → HookError +describe('7) per-push splitPattern → HookError', () => { + it('rejects splitPattern on individual push', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'a', splitPattern: '([。!?!?]+)' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /splitPattern is removed in next\.4/); + }); +}); + +// 8) request body splitPattern → 400 INVALID_PAYLOAD_FORMAT +describe('8) request body splitPattern → 400', () => { + it('rejected pre-hook with INVALID_PAYLOAD_FORMAT', async () => { + const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: () => makeLlmResponse('x') }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ decision: 'finish', pushPayloads: [{ messageKind: 'content', message: 'a' }] }), + }); + const res = await handler(makeRequest('http://localhost/instant', basePayload({ splitPattern: '([。!?!?]+)' }))); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error.code, 'INVALID_PAYLOAD_FORMAT'); + assert.match(body.error.message, /splitPattern is removed in next\.4/); + }); +}); + +// 9) tool-request decision with all content kinds — lib does not police kind/decision pairing +describe('9) decision: tool-request + all content kinds', () => { + it('ships every push and returns tool_requested', async () => { + const { result, pushes } = await runDirect({ + decision: 'tool-request', + pushPayloads: [ + { messageKind: 'content', message: 'a' }, + { messageKind: 'content', message: 'b' }, + ], + }, { autoEmitReasoning: false }); + assert.equal(result.status, 'tool_requested'); + assert.equal(pushes.length, 2); + }); +}); + +// 10) finish decision containing a tool_request kind push — also accepted +describe('10) decision: finish + tool_request kind push', () => { + it('ships the tool_request push, returns finished', async () => { + const { result, pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'narration' }, + { messageKind: 'tool_request', message: '', toolCalls: [{ id: 'c1', type: 'function', function: { name: 'x' } }] }, + ], + }, { autoEmitReasoning: false }); + assert.equal(result.status, 'finished'); + assert.equal(pushes.length, 2); + assert.equal(pushes[1].messageKind, 'tool_request'); + assert.deepEqual(pushes[1].toolCalls, [{ id: 'c1', type: 'function', function: { name: 'x' } }]); + }); +}); + +// 11) messageId precedence +describe('11) messageId hook vs auto', () => { + it('hook-set messageId is preserved; unset → lib auto-fills with unique id', async () => { + const { pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a', messageId: 'hook-set-1' }, + { messageKind: 'content', message: 'b' }, + { messageKind: 'content', message: 'c', messageId: 'hook-set-3' }, + ], + }, { autoEmitReasoning: false }); + assert.equal(pushes[0].messageId, 'hook-set-1'); + assert.equal(pushes[2].messageId, 'hook-set-3'); + assert.notEqual(pushes[1].messageId, undefined); + assert.notEqual(pushes[1].messageId, pushes[0].messageId); + assert.notEqual(pushes[1].messageId, pushes[2].messageId); + }); +}); + +// 12) messageIndex/totalMessages always overwritten +describe('12) messageIndex/totalMessages overwritten', () => { + it('caller-supplied indices are clobbered with array-derived values', async () => { + const { pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a', messageIndex: 999, totalMessages: 0 }, + { messageKind: 'content', message: 'b', messageIndex: 999, totalMessages: 0 }, + ], + }, { autoEmitReasoning: false }); + assert.deepEqual(pushes.map(p => p.messageIndex), [1, 2]); + assert.deepEqual(pushes.map(p => p.totalMessages), [2, 2]); + }); +}); + +// 13) reasoning auto-emit + pushPayloads coexist +describe('13) reasoning auto-emit precedes hook pushPayloads', () => { + it('reasoning push ships first, then hook pushes', async () => { + const { pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'final answer' }, + ], + }, { reasoning: 'thinking...' }); + assert.equal(pushes.length, 2); + assert.equal(pushes[0].messageKind, 'reasoning'); + assert.equal(pushes[0].reasoningContent, 'thinking...'); + assert.equal(pushes[1].messageKind, 'content'); + assert.equal(pushes[1].message, 'final answer'); + }); +}); From ff4b03e2678ec6fc541c2d3b2e81f876bf2fc62c Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 16:10:06 +0800 Subject: [PATCH 17/33] =?UTF-8?q?docs(amsg-instant)!:=20rewrite=20README?= =?UTF-8?q?=20=C2=A7Hook=20for=20pushPayloads-only=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the splitPattern / reasoningSplitPattern / errorSplitPattern subsections + per-push override sub-section (~150 lines). Add the new pushPayloads contract block, the messageId/messageIndex/totalMessages auto-fill table, and three worked examples (single push, 3-chunk content with per-chunk notification.body, tool-request mixing content and multi-toolCalls). Also drops the splitPattern line from the request payload typedef and the "same splitPattern as v0.6" claim in the legacy-compat note (legacy still uses the default sentence regex internally; the public knob is gone). Historical references to splitPattern in version-history sections intentionally left intact — Task 9's migration guide handles them. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/instant/README.md | 211 ++++++++++++------- 1 file changed, 130 insertions(+), 81 deletions(-) diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index c45e3d4..8c4eea9 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -131,11 +131,6 @@ Content-Type: application/json temperature?: number; // 0.5.0+:透传给 LLM;completePrompt 路径未传默认 0.8 messageSubtype?: string; // SW 端分类标签,取值由业务决定 - // === 分句正则(按 messageKind 独立配置),可选 === - splitPattern?: string | string[] | null; // content / tool_request:默认 /([。!?!?]+)/,null/[] 关闭 - reasoningSplitPattern?: string | string[] | null; // 0.8.0-next.2+,reasoning:默认不切;传了就按这个切 - errorSplitPattern?: string | string[] | null; // 0.8.0-next.2+,error:默认不切;传了就按这个切 - pushSubscription: { // Web Push 标准订阅 endpoint: string; keys: { p256dh: string; auth: string }; @@ -184,66 +179,6 @@ curl -X POST https://instant.example.com/instant \ }' ``` -#### `splitPattern` 系列:按 `messageKind` 独立的分句正则(0.6.0+ / 0.8.0-next.2+) - -LLM 返回的整段文本默认按 `/([。!?!?]+)/` 切成多条推送(每条之间间隔 1.5s,看起来像真人一句句打字)。三个字段各管一类 push 的切分: - -| 字段 | 控制的 `messageKind` | 默认(字段省略时) | -|---------------------------|--------------------------------|----------------------------| -| `splitPattern` | `content` / `tool_request` | `/([。!?!?]+)/`(开) | -| `reasoningSplitPattern` | `reasoning` | **不切** | -| `errorSplitPattern` | `error` | **不切** | - -```jsonc -// 单正则:按换行切 -{ "splitPattern": "([\\n]+)" } - -// 数组:级联——先按段落切,每段再按句号切 -{ "splitPattern": ["(\\n\\n+)", "([。!?!?]+)"] } - -// reasoning 长文本想切气泡:默认不切,得显式传 -{ "reasoningSplitPattern": "([。!?!?]+)" } - -// 关闭 content 的默认切分(整段一条 push) -{ "splitPattern": null } -``` - -**`ToolRequestPush` 切片特殊处理**:`toolCalls` 是原子数组不切。`message` 切成 N 段时前 N-1 段降级为 `messageKind: 'content'`(不带 `toolCalls`),最后一段保留 `tool_request` + 完整 `toolCalls`,保证 narration 全显示完再启动 tool 执行。 - -**通用约定**: - -- 传**正则 source**,不要带两边的 `/.../` 也不要带尾部 flag(`/foo/i` 会被当字面量斜杠 + 字面量 `i` 匹配)。需要大小写不敏感请用 `[Aa]` 这种字符类替代。 -- 想保留分隔符(默认就是把句号回贴到前一段),把分隔符包进 `(...)` 捕获组。库不会自动包——传 `"\\n+"` 而不是 `"(\\n+)"` 会得到首尾相连、分隔符丢失的奇怪结果。 -- 数组语义是**级联**(split → split → split),不是"任一匹配就切"。需要后者请用 `|` 自己合一条正则。 -- 上限:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PAYLOAD_FORMAT`。 -- **`undefined` vs `null` / `[]` 语义不同**: - - `splitPattern`:`undefined` = 用默认正则;`null` / `[]` = 关闭切分。 - - `reasoningSplitPattern` / `errorSplitPattern`:`undefined` = 不切(保守默认);`null` / `[]` 也是不切(显式关闭,效果一样)。这俩 kind 默认 off,是因为它们历史上就没切片 UX,引入 default-on 会改老 caller 行为。 - -##### Per-push override:`pushPayload.splitPattern`(0.8.0-next.3+) - -在 hook 模式下,`onLLMOutput` 返回的 `pushPayload` 自身可以带一个 `splitPattern` 字段,作用域只限**这一个 push**。它优先于请求级的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern`,规则比请求级简单一些: - -- **字段名永远是 `splitPattern`**,不分 kind——因为 push 自己的 `messageKind` 已经定了。`reasoning` push 想切片,写 `pushPayload.splitPattern: '(...)'` 即可(无需 `reasoningSplitPattern`)。 -- **优先级 / 语义区分 `undefined` vs `null`**: - - 写 `splitPattern: null`(或 `[]`)= **显式关切**(这一个 push 不切,请求级被盖住)。 - - 写 `splitPattern: '(...)'` / `splitPattern: ['(\\n+)', '(...)']` = **显式开切**(用这套正则切,请求级被盖住)。 - - `splitPattern: undefined` 或字段缺省 = **没意见**,回退到请求级 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern`。 -- **校验**:与请求级共享 `validateSplitPattern`——形状或正则非法 → 抛 `HookError`,message 形如 `pushPayload.splitPattern invalid: <原因>`,明确点位是 push 上的字段(不会跟请求级混)。 -- **wire 不带这个字段**:库会在交付前把它从 chunks 里 strip 掉,SW 永远收不到 `splitPattern`。strip 一次性完成——`splitHookPushPayload` 每个 push 跑一次,N-段切片 / ToolRequestPush 的 prefix 降级段都从已剥离的 parent spread,不会被二次切。 - -```js -onLLMOutput: async (ctx) => ({ - decision: 'finish', - pushPayload: { - ...buildContentPush({ /* ... */ }), - splitPattern: null, // 这一段不切——即使请求级的 splitPattern 是开着的 - }, -}); -``` - -什么时候用:hook 想让某一类 push(比如「短促回复」「错误提示」)整段送出,而其他 push 仍按请求级配置切分。如果你想全局关闭,仍然直接在请求 body 上传 `splitPattern: null` 更省事。 - #### `apiUrl` 规范化(0.4.0+) 为了让用户不必死记 OpenAI 路径全名,Worker 会按下表规则补全 `apiUrl`。规则幂等:跑两次 = 跑一次,所以传完整 URL 也不会被改坏。 @@ -292,8 +227,8 @@ onLLMOutput: async (ctx) => ({ | `LLM_CALL_FAILED` | 502 | 0.1 | 上游 LLM 请求失败 | | `PUSH_SEND_FAILED` | 502 | 0.1 | Web Push 派送失败 | | `COMPLETE_PROMPT_NOT_SUPPORTED_ON_HOOK_PATH` | 400 | 0.7 | 配了 `onLLMOutput` 之后 `/instant` 或 `/continue` 还传 `completePrompt`;hook 路径只接受 `messages` 数组 | -| `HOOK_THREW` | 500 | 0.7 | `onLLMOutput` 抛错或返了非法 decision(`null` / 不识别的 `decision` 值 / `pushPayload` 不可 JSON-serialize)。同时会推一条诊断 push(payload `{type:'error', code:'HOOK_THREW',...}`) | -| `PAYLOAD_TOO_LARGE` | 500 | 0.7 | hook 返的 `pushPayload` UTF-8 字节超 `maxInlineBytes` 且没配 `blobStore`。配上 BlobStore 自动走 envelope 转发 | +| `HOOK_THREW` | 500 | 0.7 | `onLLMOutput` 抛错或返了非法 decision(`null` / 不识别的 `decision` 值 / `pushPayloads` 不是数组或为空 / 单个 push 不可 JSON-serialize)。同时会推一条诊断 push(payload `{type:'error', code:'HOOK_THREW',...}`) | +| `PAYLOAD_TOO_LARGE` | 500 | 0.7 | hook 返的 `pushPayloads` 中某个 push UTF-8 字节超 `maxInlineBytes` 且没配 `blobStore`。配上 BlobStore 自动走 envelope 转发 | | `CONTINUE_NOT_AVAILABLE` | 400 | 0.7 | 往没配 `onLLMOutput` 的 handler POST `/continue`。`/continue` 是 agentic loop 的续跑端点,没钩子就没东西可续,直接拒掉避免误报成 `HOOK_THREW` | | `INTERNAL_ERROR` | 500 | 0.1 | 其他未分类内部错误 | @@ -332,7 +267,7 @@ v0.7 在 v0.6 之上**追加**了一个 hook 路径:配置 `onLLMOutput` 后 **两条路径互不干扰**: - **不配 `onLLMOutput`** → 原 v0.6 单次 LLM + 分句 + 串行 push(默认 1500 ms 间隔,13 字段 payload)。字节级与 v0.6 一致。 -- **配了 `onLLMOutput`** → 进 agentic loop,每轮 LLM 输出后调 hook 做 decision;hook 返什么就执行什么。`splitPattern` 在这条路径下不会被读,启动期会 `console.warn` 提示。 +- **配了 `onLLMOutput`** → 进 agentic loop,每轮 LLM 输出后调 hook 做 decision;hook 返什么就执行什么。切分完全由 hook 自己负责(`pushPayloads` 数组),见 [切分由 caller 负责](#切分由-caller-负责080-next4-起)。 ### hook 签名 @@ -357,13 +292,125 @@ onLLMOutput(ctx: SessionContext): LLMOutputDecision | Promise ```ts type LLMOutputDecision = - | { decision: 'finish'; pushPayload: unknown } // 推送 final,结束链路 - | { decision: 'tool-request'; pushPayload: unknown } // 推送 tool-request,等 /continue - | { decision: 'continue'; nextHistory: ChatMessage[] } // worker 内部再来一轮 LLM,不推送 - | { decision: 'skip-push' } // 直接结束链路、不推送(罕见) + | { decision: 'finish'; pushPayloads: PushPayload[] } // 推送 N 条 push,结束链路 + | { decision: 'tool-request'; pushPayloads: PushPayload[] } // 推送 N 条 push,等 /continue + | { decision: 'continue'; nextHistory: ChatMessage[] } // worker 内部再来一轮 LLM,不推送 + | { decision: 'skip-push' } // 直接结束链路、不推送(罕见) +``` + +**没有单数 `pushPayload` 字段了。** 1 条就 `[push]`,3 条就 `[a, b, c]`。空数组 `[]` 非法(直接 `HookError`)。 + +lib 给每个 push 自动补这 3 个机械字段(hook 自己设 `messageId` 会被尊重,其余 2 个无论 hook 写什么都被覆盖): + +| 字段 | 自动补充行为 | +|-----------------|---------------------------------------------------| +| `messageId` | 未设时 lib 用 `msg__chunk_` 填上 | +| `messageIndex` | 永远是 1-based 数组下标 + 1(hook 写啥都覆盖) | +| `totalMessages` | 永远是 `pushPayloads.length` | + +剩下所有字段(`messageKind` / `notification` / `metadata` / `messageKind` 特定字段 / 等)都是 per-push,caller 完全控制。每个 push 必须 **JSON-safe**(无循环引用 / BigInt / function 字段),否则被当作 hook 契约违反走 `HookError` / `HOOK_THREW` 路径。 + +### 切分由 caller 负责(0.8.0-next.4 起) + +next.4 起 lib 不再做任何拆分。hook 返 `pushPayloads: PushPayload[]`,里面装的就是 lib 会原样依次发的 N 条 push。常见 caller 会自己实现: + +- 按 `\n` 或 CJK 字符之间的空格切(lookbehind / lookahead 写得出来) +- 按 inline tag(比如 `[[SEND_EMOJI: xxx]]`)独立成段 +- 切完空段 `filter`、按业务规则前后 `merge` / `split` 二阶段 +- per-chunk `notification.body` 用 sanitized 文本,`message` 字段保留 raw + +如果想要 0.7 / next.2 / next.3 的「默认 `/([。!?!?]+)/` 句切」行为,自己写: + +```js +const segments = text.split(/([。!?!?]+)/g) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter((s) => s.length > 0); + +return { + decision: 'finish', + pushPayloads: segments.map((message) => ({ + messageKind: 'content', + sessionId: ctx.sessionId, + message, + notification: { title: `来自 ${ctx.contactName}`, body: message }, + })), +}; +``` + +请求 body 上的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 在 next.4 里直接 400;push 上带 `splitPattern` 抛 `HookError`。pre-release 强迫一次性改干净。 + +#### 例 1:单 push + +```js +return { + decision: 'finish', + pushPayloads: [{ + messageKind: 'content', + sessionId: ctx.sessionId, + message: 'Hello', + notification: { title: 'Sully', body: 'Hello' }, + }], +}; +``` + +#### 例 2:3 chunk content + 不同 notification.body(banner 显示 sanitized,bubble 显示 raw) + +```js +return { + decision: 'finish', + pushPayloads: [ + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '你看', + notification: { title: '来自 Sully', body: '你看' }, + }, + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '[[SEND_EMOJI: 笑]]', // raw 给客户端 app + notification: { title: '来自 Sully', body: '[表情:笑]' }, // sanitized 给 banner + }, + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '我没事的', + notification: { title: '来自 Sully', body: '我没事的' }, + }, + ], +}; ``` -`pushPayload` 必须 **JSON-safe**(无循环引用 / BigInt / function 字段),否则被当作 hook 契约违反走 `HookError` / `HOOK_THREW` 路径。 +#### 例 3:tool-request 混 content + 多 toolCalls + +```js +return { + decision: 'tool-request', + pushPayloads: [ + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '让我同时查日记和天气', + notification: { title: '来自 Sully', body: '让我同时查日记和天气' }, + }, + { + messageKind: 'tool_request', + sessionId: ctx.sessionId, + message: '', + toolCalls: [ + { id: 'rd_1', type: 'function', function: { name: 'notion_read_diary', arguments: '{"date":"2024-05-21"}' } }, + { id: 'ws_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"北京天气"}' } }, + ], + // 无 notification → 不弹 OS 横幅 + }, + ], +}; +``` + +decision 跟 push 内容的 `messageKind` 分布完全解耦——lib 不检查「`tool-request` decision 是不是必须含 `tool_request` push」之类的搭配,hook 想怎么组合就怎么组合。 ### `decision: 'continue'` + `nextHistory` 的脚枪 @@ -472,13 +519,15 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): reasoning-heavy LLM(DeepSeek-R1 / GLM-4.5 / Qwen3-Thinking 等)经常输出 3-10 KB `reasoning_content`,远超 Web Push 单 payload ~2.6 KB 安全线。next.2 内置 transparent 字节切分:framework 在产出 `ReasoningPush` 时自动按 UTF-8 codepoint 边界切成 N 份带 `chunkIndex` / `totalChunks` 投递,SW 拼回完整字符串。**绝大多数 reasoning-heavy 部署不再需要 BlobStore。** -### 两层 cascade +> **0.8.0-next.4 BREAKING**:`reasoningSplitPattern` 已移除(请求 body 带它直接 400)。auto-emit 路径只剩单层字节切;想要句切,自己在 hook 里切完用 `pushPayloads` 返回。下文「两层 cascade」描述的是 next.2 / next.3 历史行为,next.4 起 Layer 1 永远 OFF(且无法开启)。 + +### 两层 cascade(next.2 / next.3 历史;next.4 仅剩 Layer 2) ``` reasoningContent │ ▼ -Layer 1 — 语义切(reasoningSplitPattern,默认 OFF) +Layer 1 — 语义切(reasoningSplitPattern,next.4 已移除) • 按 regex 切成 M 段,每段带 messageIndex 1..M / totalMessages M │ ▼ 对每个 Layer-1 段独立量字节 @@ -518,9 +567,9 @@ createInstantHandler({ }); ``` -`reasoningSplitPattern` 和 `reasoningChunkBytes` 是**两个独立开关**: -- `reasoningSplitPattern: null` 只关 Layer 1(句切),不影响 Layer 2 字节切。 -- `reasoningChunkBytes: null` 只关 Layer 2(字节切),不影响 Layer 1 句切。 +`reasoningChunkBytes: null` 关 Layer 2(字节切),等大 reasoning 时走 BlobStore 兜底。 + +> next.2 / next.3 还有 `reasoningSplitPattern` 控制 Layer 1,next.4 已移除。想要 reasoning 句切的 caller,自己在 hook 里切完用 `pushPayloads` 数组返回(lib 不再做语义切)。 ### Wire format @@ -607,7 +656,7 @@ function onReasoningPush(p) { **关键不变量**: - `chunkIndex` / `totalChunks` 仅在 byte 切实际发生(N > 1)时出现,单 chunk 一律省略。 -- `messageIndex` / `totalMessages` 仅在 `reasoningSplitPattern` 实际切了(M > 1)时出现。 +- `messageIndex` / `totalMessages` 仅在 caller 自己用 `pushPayloads` 数组提交了 M > 1 条 reasoning push 时出现(next.4 起 auto-emit 路径只剩单层字节切,不再加这俩字段)。 - Web Push 到达顺序**不保证**,SW 必须按 `chunkIndex` 排序。 - 跨 sessionId 不要混。每个 LLM round 一个 sessionId。 @@ -817,8 +866,8 @@ Redis / Upstash 等带原生 TTL 的后端不用挂;KV 同理。 **绝大多数 v0.6 用户什么都不用改**。包升到 0.7.0 后: -- 不配 `onLLMOutput` → 跑 legacy 路径,字节级与 v0.6 一致(同 13 字段 payload、同 1500 ms 间隔、同 `splitPattern`、同 `onEvent` 事件) -- `splitPattern` 数组、`messageSubtype`、`metadata` 等所有 v0.6 字段保持原样 +- 不配 `onLLMOutput` → 跑 legacy 路径,字节级与 v0.6 一致(同 13 字段 payload、同 1500 ms 间隔、同 `onEvent` 事件)。legacy 路径内部仍按 `/([。!?!?]+)/` 默认句切,**只是**请求 body 上的 `splitPattern` 公共旋钮在 0.8.0-next.4 里被移除了(详见 0.8 迁移指南)。 +- `messageSubtype`、`metadata` 等所有 v0.6 字段保持原样 - `buildInstantPushPayload` 现在是 public helper —— 你的 v0.6 测试如果之前 monkey-patch 过它,现在可以直接 import 只在你**想用 agentic loop** 的时候才动配置:把 `onLLMOutput` 加进 `createInstantHandler` 入参,里头自己决定 finish / tool-request / continue / skip-push。其他都不需要碰。 From b23355c0848db1dc51e2a33ad5ac871d6faefe0b Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 16:14:44 +0800 Subject: [PATCH 18/33] =?UTF-8?q?docs(amsg-instant):=20README=20polish=20?= =?UTF-8?q?=E2=80=94=20fix=20messageKind=20dup=20+=20auto-fill=20table=20u?= =?UTF-8?q?niformity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two code-review follow-ups to ff4b03e: - L311 said `messageKind / ... / messageKind 特定字段`; second occurrence generalised to "kind 特定字段" with concrete examples. - 3-row auto-fill table normalised to the same "when + what" pattern per row. No semantic change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/instant/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 8c4eea9..cf9726e 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -302,13 +302,13 @@ type LLMOutputDecision = lib 给每个 push 自动补这 3 个机械字段(hook 自己设 `messageId` 会被尊重,其余 2 个无论 hook 写什么都被覆盖): -| 字段 | 自动补充行为 | -|-----------------|---------------------------------------------------| -| `messageId` | 未设时 lib 用 `msg__chunk_` 填上 | -| `messageIndex` | 永远是 1-based 数组下标 + 1(hook 写啥都覆盖) | -| `totalMessages` | 永远是 `pushPayloads.length` | +| 字段 | 自动补充行为 | +|-----------------|-------------------------------------------------------------------| +| `messageId` | hook 未设 → lib 用 `msg__chunk_` 填上;hook 已设 → 保留 | +| `messageIndex` | 永远覆盖:1-based 数组下标(i + 1) | +| `totalMessages` | 永远覆盖:`pushPayloads.length` | -剩下所有字段(`messageKind` / `notification` / `metadata` / `messageKind` 特定字段 / 等)都是 per-push,caller 完全控制。每个 push 必须 **JSON-safe**(无循环引用 / BigInt / function 字段),否则被当作 hook 契约违反走 `HookError` / `HOOK_THREW` 路径。 +剩下所有字段(`messageKind` / `notification` / `metadata` / kind 特定字段(e.g. tool_request 的 toolCalls / reasoning 的 reasoningContent / error 的 code) / 等)都是 per-push,caller 完全控制。每个 push 必须 **JSON-safe**(无循环引用 / BigInt / function 字段),否则被当作 hook 契约违反走 `HookError` / `HOOK_THREW` 路径。 ### 切分由 caller 负责(0.8.0-next.4 起) From 108843028bdc59302a5f7db5113293dfcd0f0f78 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 16:18:54 +0800 Subject: [PATCH 19/33] docs(amsg-instant)!: 0.8.0-next.4 CHANGELOG + migration guide + version bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release breaking change documented end-to-end: - CHANGELOG.md: BREAKING section enumerating Removed / Changed / Unchanged + migration cheat sheet - docs/migration-0.8.0-next.4.md: long-form companion with motivation, 5 worked recipes (one-shot, splitPattern→caller, per-chunk notification, tool-request mixed kinds, manual reasoning), FAQ, and stable-bits inventory - package.json: 0.8.0-next.3 → 0.8.0-next.4 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 39 +++ .../instant/docs/migration-0.8.0-next.4.md | 283 ++++++++++++++++++ .../rei-standard-amsg/instant/package.json | 2 +- 3 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 07875d8..54619b2 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.4 — BREAKING: pushPayloads-only hook decision API (pre-release) + +Install with `npm install @rei-standard/amsg-instant@next`. Pre-release — breaking on purpose. 见 [`docs/migration-0.8.0-next.4.md`](./docs/migration-0.8.0-next.4.md) 完整迁移指南. + +### Removed + +- `decision.pushPayload` (singular). Replaced by `decision.pushPayloads: PushPayload[]`. +- Request-body fields `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` — rejected with 400 `INVALID_PAYLOAD_FORMAT` and a migration hint pointing at `pushPayloads`. +- `pushPayload.splitPattern` per-push override (next.3 only) — rejected with `HookError`. +- Public export `splitMessageIntoSentences` — used to be exported from `@rei-standard/amsg-instant` for hook authors who wanted "the same default split as the legacy path". The legacy path still uses it internally; hook authors implement their own split. +- Internal helpers `splitHookPushPayload` / `splitOnceByRegex` / `pickSplitConfig` / `validateSplitPattern` / `validatePerKindSplitPatterns` / `DEFAULT_SPLIT_REGEX` / `SPLIT_PATTERN_MAX_*` — all gone (or only kept where needed for the legacy path). +- The two-layer reasoning cascade collapsed to one layer (byte chunking). The Layer-1 sentence split via `reasoningSplitPattern` is gone with the field. + +### Changed + +- `runAgenticLoop`'s finish / tool-request branch now reads `decision.pushPayloads` and ships each push via `sendPushWithMaybeBlob` with `SLEEP_BETWEEN_MESSAGES_MS` (1500ms) between consecutive pushes. Per-push: `messageId` is auto-filled when absent (`msg__chunk_`); `messageIndex` / `totalMessages` are always overwritten with array-derived values. +- LOOP_EXCEEDED diagnostic is now a single `sendPushWithMaybeBlob` call (no looping needed — the diagnostic is one push). +- Reasoning auto-emit (`autoEmitReasoning: true`, default): now a single transform. Short reasoning → 1 push; oversized → N byte-chunked pushes with `chunkIndex` / `totalChunks` (Layer-2 only). + +### Unchanged + +- Legacy v0.6 compat path (no `onLLMOutput`) still splits raw LLM text by sentence regex and ships sequential pushes — byte-level identical to v0.6. The public `splitPattern` knob on the request body is gone, but the path's internal behaviour is preserved (default regex `/([。!?!?]+)/`). +- HOOK_THREW handling (single-shot diagnostic, best-effort delivery), blob envelope, `maxLoopIterations`, `autoEmitReasoning`, `reasoningChunkBytes`, all 4 decisions (`finish` / `tool-request` / `continue` / `skip-push`). +- VAPID / push subscription / `apiKey` are still not exposed to the hook. +- HTTP status code mapping unchanged. + +### Migration cheat sheet + +| next.3 | next.4 | +|-------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| `return { decision: 'finish', pushPayload: { ... } }` | `return { decision: 'finish', pushPayloads: [{ ... }] }` | +| Request body `splitPattern: '([。!?!?]+)'` | Implement the split in your hook; return one push per segment | +| `pushPayload.splitPattern: null` (per-push disable from next.3) | Return `pushPayloads: [singleUnsplit]` | +| `reasoningSplitPattern` request field | Set `autoEmitReasoning: false`, build N reasoning pushes yourself with `buildReasoningPush(...)`, include them at the start of `pushPayloads` | + +### Why breaking in pre-release + +The `0.8.0-next.*` series is pre-1.0 unstable. next.2 + next.3 stacked two overlapping mechanisms (lib-side splitPattern auto-split + hook-side pushPayload singular). next.4 collapses both into one (caller returns the exact pushes it wants sent) before 1.0 freezes the public surface. + ## 0.8.0-next.3 — `pushPayload.splitPattern` per-push override (pre-release) Coordinated with `@rei-standard/amsg-shared@0.1.0-next.3`. Install with `npm install @rei-standard/amsg-instant@next`. diff --git a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md new file mode 100644 index 0000000..5214bee --- /dev/null +++ b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md @@ -0,0 +1,283 @@ +# Migration guide — 0.8.0-next.3 → 0.8.0-next.4 + +`0.8.0-next.*` is a pre-release line. next.4 is intentionally breaking because we're consolidating two overlapping mechanisms (lib-side `splitPattern` auto-split + hook-side singular `pushPayload`) into one: **the hook returns the exact N pushes it wants sent, and the lib does zero splitting.** + +This guide expands on the CHANGELOG. If you only want a one-pager, [`CHANGELOG.md`](../CHANGELOG.md) has the cheat sheet. + +## Why + +next.2/next.3 had the hook return a single `pushPayload`. The lib then ran an internal `splitHookPushPayload` that: + +- Used a `splitPattern` regex to chop the `message` field into N parts. +- Cloned the whole pushPayload N times, replacing only the `message` field per clone. +- Sent each clone as its own Web Push. + +Two problems made this leak abstractions: + +### Problem 1: regex-driven splitting can't express what real callers need + +A single-pass regex can't: + +- Cut on `\n` AND on the boundary between two CJK characters in the same call. +- Pull `[[SEND_EMOJI: xxx]]` inline tags out as their own segment between sentences. +- Drop empty segments after the cut and then merge / re-split by business rules. + +Callers wanted real JS functions to do the split, not regex sources. The framework's "give us a regex" API was strictly less expressive. + +### Problem 2: per-chunk fields had to differ + +`splitHookPushPayload` cloned every field besides `message`. But real callers needed per-chunk variation in: + +- `notification.body` — OS banner shows sanitized text; `message` keeps raw text for client app post-processing. Banner text and bubble text disagree. +- `metadata.directives` — side-effect markers must appear on exactly one of N pushes (else client replays N times). +- `metadata.iteration` — agentic-loop state per chunk. +- `messageId` — must be unique per push (SW IDB keyPath; duplicates overwrite). + +`splitHookPushPayload`'s clone model couldn't express any of this. + +next.4's solution is the simplest possible: hook returns `pushPayloads: PushPayload[]`, the lib delivers each element in order with 1500ms spacing. No more clone-and-replace. + +## API contract + +```ts +type HookDecision = + | { decision: 'finish'; pushPayloads: PushPayload[] } + | { decision: 'tool-request'; pushPayloads: PushPayload[] } + | { decision: 'continue'; nextHistory: ChatMessage[] } + | { decision: 'skip-push' }; +``` + +**No singular `pushPayload` field.** One push: `[push]`. Three pushes: `[a, b, c]`. + +The lib auto-fills these three "mechanical" fields per push: + +| Field | When | Value | +|-----------------|----------------------------------------------|--------------------------------------| +| `messageId` | Hook didn't set one (`messageId === undefined`) | `msg__chunk_` (auto) | +| `messageIndex` | Always overwritten | 1-based array index (`i + 1`) | +| `totalMessages` | Always overwritten | `pushPayloads.length` | + +Every other field on each push (`messageKind`, `notification`, `metadata`, kind-specific fields like `toolCalls` / `reasoningContent`) is per-push, fully under caller control. + +## What gets rejected + +Six things now trip a contract error (HOOK_THREW + HTTP 500 for hook-side, 400 INVALID_PAYLOAD_FORMAT for request-body): + +1. **`decision.pushPayload` (singular)** → `HookError`: "pushPayload (singular) is removed in next.4, use pushPayloads: [yourPayload]" +2. **Both `pushPayload` and `pushPayloads` set** → `HookError`: "use pushPayloads" +3. **`pushPayloads: []`** → `HookError`: "use decision: skip-push to skip notification entirely" +4. **`pushPayloads[i].splitPattern`** → `HookError`: "splitPattern is removed in next.4" +5. **Request body `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern`** → 400 INVALID_PAYLOAD_FORMAT: " is removed in next.4; caller is responsible for splitting" +6. **`pushPayloads[i]` not a plain object** → `HookError`: "pushPayloads[i] must be a plain object" + +Pre-release strictness: no warnings, no silent fallback. You change the call sites, OR your turn 500s on the first hook invocation. + +## Migration recipes + +### Recipe 1: one-shot finish (no split, no fancy) + +Before: +```js +return { + decision: 'finish', + pushPayload: { + messageKind: 'content', + sessionId: ctx.sessionId, + message: 'Hello', + notification: { title: 'Sully', body: 'Hello' }, + }, +}; +``` + +After (wrap in array): +```js +return { + decision: 'finish', + pushPayloads: [{ + messageKind: 'content', + sessionId: ctx.sessionId, + message: 'Hello', + notification: { title: 'Sully', body: 'Hello' }, + }], +}; +``` + +### Recipe 2: `splitPattern` → caller-side split + +Before (request body `splitPattern`): +```js +// Worker config or per-request body field: +{ ..., splitPattern: '([。!?!?]+)' } + +// Hook: +return { + decision: 'finish', + pushPayload: { + messageKind: 'content', + message: 'A。B。C。', + }, +}; +``` + +After (caller implements split, returns N pushes): +```js +// In hook, after computing the LLM output text: +const segments = text + .split(/([。!?!?]+)/g) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter((s) => s.length > 0); + +return { + decision: 'finish', + pushPayloads: segments.map((message) => ({ + messageKind: 'content', + sessionId: ctx.sessionId, + message, + notification: { title: `来自 ${ctx.contactName}`, body: message }, + })), +}; +``` + +This pattern is verbose by design — caller now sees exactly what the lib was doing internally, and can replace `String.prototype.split(/.../)` with a richer tokenizer if needed. + +### Recipe 3: per-chunk `notification.body` (THE feature this unlocks) + +Use case: client-app `message` text contains tags / emoji codes / inline markup. OS banner must show sanitized text. + +```js +return { + decision: 'finish', + pushPayloads: [ + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '你看', + notification: { title: '来自 Sully', body: '你看' }, + }, + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '[[SEND_EMOJI: 笑]]', // raw, client renders as emoji + notification: { title: '来自 Sully', body: '[表情:笑]' }, // sanitized for OS banner + }, + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '我没事的', + notification: { title: '来自 Sully', body: '我没事的' }, + }, + ], +}; +``` + +Pre-next.4, this required either dropping inline tags from `message` (losing client semantics) or skipping `notification` entirely (no banner). With per-push `notification`, both audiences get what they want. + +### Recipe 4: tool-request with mixed kinds + +Use case: hook wants narration to ship first, then a tool call to execute. + +```js +return { + decision: 'tool-request', + pushPayloads: [ + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '让我同时查日记和天气', + notification: { title: '来自 Sully', body: '让我同时查日记和天气' }, + }, + { + messageKind: 'tool_request', + sessionId: ctx.sessionId, + message: '', + toolCalls: [ + { id: 'rd_1', type: 'function', function: { name: 'notion_read_diary', arguments: '{"date":"2024-05-21"}' } }, + { id: 'ws_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"北京天气"}' } }, + ], + // No notification — client doesn't show a banner for the tool call itself. + }, + ], +}; +``` + +decision tag and per-push `messageKind` are now decoupled: a `decision: 'tool-request'` decision can contain content pushes (and probably should — `tool_request` kind pushes typically have empty `message`). The lib doesn't policy-check the mix. + +### Recipe 5: `reasoningSplitPattern` → manual reasoning splitting + +Before: +```js +// Request body: reasoningSplitPattern: '([。!?!?]+)' +// autoEmitReasoning: true (default) +// Hook returns: { decision: 'finish', pushPayload: { messageKind: 'content', ... } } +// Lib auto-emits reasoning push split into N chunks by Layer-1 sentence regex. +``` + +After (caller handles reasoning emission): +```js +// In handler setup: +createInstantHandler({ + ..., + autoEmitReasoning: false, // we'll build it ourselves +}); + +// In hook: +import { buildReasoningPush } from '@rei-standard/amsg-instant'; + +const reasoning = ctx.llmResponse.choices[0].message.reasoning_content; +const reasoningSegments = reasoning + ? reasoning.split(/([。!?!?]+)/g) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter((s) => s.length > 0) + : []; + +const reasoningPushes = reasoningSegments.map((piece) => buildReasoningPush({ + messageType: 'instant', + source: 'instant', + messageId: `msg_${randomUUID()}_iter_${ctx.iteration}_reasoning_${i}`, + sessionId: ctx.sessionId, + reasoningContent: piece, + timestamp: new Date().toISOString(), +})); + +const contentPushes = [{ + messageKind: 'content', + sessionId: ctx.sessionId, + message: ctx.llmOutputText, + notification: { title: `来自 ${ctx.contactName}`, body: ctx.llmOutputText }, +}]; + +return { + decision: 'finish', + pushPayloads: [...reasoningPushes, ...contentPushes], +}; +``` + +If you only need byte-bound transparent chunking (the common case for DeepSeek-R1 / GLM-4.5 / Qwen3-Thinking heavy-reasoning responses), keep `autoEmitReasoning: true` (default) and skip this whole recipe — the lib still handles Layer-2 byte chunking via `chunkReasoningByUtf8Bytes` and ships it with `chunkIndex` / `totalChunks` per chunk. + +## Stable bits — what didn't change + +- VAPID config, `pushSubscription`, `apiKey`, `clientToken` / `tokenSigningKey` — same as next.3. +- `BlobStore` and the `_blob` envelope for over-cap payloads — same. +- `maxLoopIterations` (default 10) — same. +- `autoEmitReasoning` (default `true`) and `reasoningChunkBytes` (default 2000, `null` to disable) — same. +- The 4 `decision` values (`finish` / `tool-request` / `continue` / `skip-push`) — same. +- HTTP status codes (200 / 400 / 401 / 500 / 502) — same. +- `onEvent` event taxonomy — same names (`llm_done`, `final_pushed`, `tool_request_pushed`, `reasoning_pushed`, etc.). + +## Questions a reviewer should ask + +- "Is the per-push `notification` actually being read by the SW?" Yes — the SW reads `notification.{title,body,icon,badge,tag,renotify,requireInteraction}` and shows them. The amsg-shared package's `ContentPush` / `ToolRequestPush` typedefs have included `notification?` since next.3. +- "Does the lib still de-dup pushes if I ship the same `messageId` twice?" No — the SW's IDB keyPath is `messageId`, so duplicates overwrite. The lib auto-fills unique ids only when the hook doesn't supply one. If the hook sets `messageId` manually, the hook is responsible for uniqueness. +- "What if I send 100 pushes in one `pushPayloads`?" The lib will ship all 100 with 1500ms spacing — total wall-clock ~150s. Probably hits the worker's CPU/wall-time budget. The spec doesn't enforce a hard cap; if you need that, validate in your hook. +- "Can I pass an async push (e.g., await an upstream API per push)?" No — `pushPayloads` is a plain array of fully-formed pushes. Build everything in the hook before returning the decision. The lib doesn't iterate a generator. + +## Open questions / future work + +- Adding a per-push delay knob (some chunks may want instant delivery, others want a longer pause for typing-bubble UX). next.4 keeps the global 1500ms; if downstream apps need fine-grained timing, file an issue with use cases. +- Optional `validatePushPayload(push)` helper exported from `@rei-standard/amsg-shared` — currently the lib just validates shape; field-level schema validation per messageKind would help hook authors catch typos before delivery. diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 373c6ee..76ad3bf 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.3", + "version": "0.8.0-next.4", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", From c98db03bd08560bf9396da66c92806fc874e9f83 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 17:01:33 +0800 Subject: [PATCH 20/33] docs(amsg-instant): fix Recipe 5 i-scope typo + clarify randomUUID source + CHANGELOG wording Three code-review follow-ups to 1088430: - Recipe 5's .map((piece) => ...) referenced an out-of-scope `i`; fixed to .map((piece, i) => ...). - Recipe 5 now imports `randomUUID` from node:crypto explicitly (or globalThis.crypto.randomUUID() on Workers/browsers) so the messageId template isn't an unresolved reference. - CHANGELOG "Removed" subsection's "all gone (or only kept where needed)" wording rewritten to honestly say which helpers stayed for the legacy path. No code change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/instant/CHANGELOG.md | 2 +- .../rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 54619b2..a850062 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -10,7 +10,7 @@ Install with `npm install @rei-standard/amsg-instant@next`. Pre-release — brea - Request-body fields `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` — rejected with 400 `INVALID_PAYLOAD_FORMAT` and a migration hint pointing at `pushPayloads`. - `pushPayload.splitPattern` per-push override (next.3 only) — rejected with `HookError`. - Public export `splitMessageIntoSentences` — used to be exported from `@rei-standard/amsg-instant` for hook authors who wanted "the same default split as the legacy path". The legacy path still uses it internally; hook authors implement their own split. -- Internal helpers `splitHookPushPayload` / `splitOnceByRegex` / `pickSplitConfig` / `validateSplitPattern` / `validatePerKindSplitPatterns` / `DEFAULT_SPLIT_REGEX` / `SPLIT_PATTERN_MAX_*` — all gone (or only kept where needed for the legacy path). +- Most internal split helpers (`splitHookPushPayload` / `pickSplitConfig` / `validatePerKindSplitPatterns` / `validateSplitPattern` / `SPLIT_PATTERN_MAX_*`) removed. `splitMessageIntoSentences` / `splitOnceByRegex` / `DEFAULT_SPLIT_REGEX` stay module-internal because `runLegacyInstant` still uses them. - The two-layer reasoning cascade collapsed to one layer (byte chunking). The Layer-1 sentence split via `reasoningSplitPattern` is gone with the field. ### Changed diff --git a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md index 5214bee..50d2d2e 100644 --- a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md +++ b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md @@ -225,6 +225,7 @@ createInstantHandler({ // In hook: import { buildReasoningPush } from '@rei-standard/amsg-instant'; +import { randomUUID } from 'node:crypto'; // or globalThis.crypto.randomUUID() on Cloudflare Workers / browsers const reasoning = ctx.llmResponse.choices[0].message.reasoning_content; const reasoningSegments = reasoning @@ -236,7 +237,7 @@ const reasoningSegments = reasoning .filter((s) => s.length > 0) : []; -const reasoningPushes = reasoningSegments.map((piece) => buildReasoningPush({ +const reasoningPushes = reasoningSegments.map((piece, i) => buildReasoningPush({ messageType: 'instant', source: 'instant', messageId: `msg_${randomUUID()}_iter_${ctx.iteration}_reasoning_${i}`, From 0f8f223d33ef6aa8375a390fd3277e9a38358774 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 17:11:15 +0800 Subject: [PATCH 21/33] docs(amsg-instant): scrub stale exports + add server-divergence note + dead-code cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pre-tag cleanup from aggregate review: - README §Migrating from v0.6 / §Public exports no longer claim buildInstantPushPayload + splitMessageIntoSentences are exported (next.0 / next.4 removed them respectively). - migration-0.8.0-next.4.md adds a "amsg-server unaffected" note so callers maintaining both packages don't accidentally carry the request-body splitPattern across. - runLegacyInstant drops `payload.splitPattern ?? null` — validation now rejects splitPattern unconditionally, so the fallback is dead code. - examples/README.md drops the splitPattern row that referenced amsg-instant. No code-behaviour change; tests still 128/128. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/README.md | 1 - packages/rei-standard-amsg/instant/README.md | 3 +-- .../rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md | 4 ++++ packages/rei-standard-amsg/instant/src/message-processor.js | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/README.md b/examples/README.md index 795e137..fa7d84a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +9,6 @@ > | 增量 | SDK 起始版本 | 示例缺什么 | > |---|---|---| > | OpenAI 格式 `messages` 数组(system / 多轮 / tool role)+ `temperature` 透传 | server 2.2.0 · instant 0.5.0 · client 2.2.1 | `lib/message-processor.js` 的 `buildAiRequestBody` 把 prompt 硬包成单条 user 消息 | -> | `splitPattern` 自定义分句正则(`string \| string[]`,级联) | server 2.3.0 · instant 0.6.0 | 仍硬编码 `/([。!?!?]+)/` 分句 | > | `avatarUrl` 软清空(不合法值 `console.warn` + 置空,不再 400 整个任务) | server 2.3.3 / 2.4.0-next.1 · instant 0.7.1 / 0.8.0-next.1 · client 2.2.4 / 2.3.0-next.1 | 只检 `new URL(...)` 能 parse;`data:` base64 头像会进库再触发下游 413 | > > 新接入请直接用 SDK 包(`@rei-standard/amsg-server` / `amsg-instant` / `amsg-client`),行为已按规范对齐到字节级。这份示例的文档与代码后续会同步更新。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index cf9726e..fce4475 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -868,7 +868,7 @@ Redis / Upstash 等带原生 TTL 的后端不用挂;KV 同理。 - 不配 `onLLMOutput` → 跑 legacy 路径,字节级与 v0.6 一致(同 13 字段 payload、同 1500 ms 间隔、同 `onEvent` 事件)。legacy 路径内部仍按 `/([。!?!?]+)/` 默认句切,**只是**请求 body 上的 `splitPattern` 公共旋钮在 0.8.0-next.4 里被移除了(详见 0.8 迁移指南)。 - `messageSubtype`、`metadata` 等所有 v0.6 字段保持原样 -- `buildInstantPushPayload` 现在是 public helper —— 你的 v0.6 测试如果之前 monkey-patch 过它,现在可以直接 import +- 想自己拼 ContentPush(v0.6 时代会去 monkey-patch `buildInstantPushPayload` 的场景)现在直接用 `buildContentPush(...)` —— 它由 `@rei-standard/amsg-shared` 实现、并从 `@rei-standard/amsg-instant` 原样 re-export,不用额外加依赖 只在你**想用 agentic loop** 的时候才动配置:把 `onLLMOutput` 加进 `createInstantHandler` 入参,里头自己决定 finish / tool-request / continue / skip-push。其他都不需要碰。 @@ -1030,7 +1030,6 @@ await client.sendInstant({ - `createInstantHandler(options)` - `validateInstantPayload(payload)` -- `splitMessageIntoSentences(text)` - `processInstantMessage(payload, ctx)` - `normalizeAiApiUrl(apiUrl)` — 0.4.0 新增,幂等地补全 `/v1/chat/completions` - `sendWebPush({ subscription, payload, vapid, ttl?, fetch? })` — 0.3.0 新增,纯 Web Crypto 实现 diff --git a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md index 50d2d2e..4c7cada 100644 --- a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md +++ b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md @@ -271,6 +271,10 @@ If you only need byte-bound transparent chunking (the common case for DeepSeek-R - HTTP status codes (200 / 400 / 401 / 500 / 502) — same. - `onEvent` event taxonomy — same names (`llm_done`, `final_pushed`, `tool_request_pushed`, `reasoning_pushed`, etc.). +## Note: `amsg-server` unaffected + +This breaking change is `amsg-instant`-only. `@rei-standard/amsg-server` (the scheduled-message package, current stable 2.3.x) keeps `splitPattern` as a request-body field — its scheduling flow has different ergonomics. Don't carry your `splitPattern: '...'` from the server's `/schedule-message` payload over to the instant's `/instant` request body — they diverge here. + ## Questions a reviewer should ask - "Is the per-push `notification` actually being read by the SW?" Yes — the SW reads `notification.{title,body,icon,badge,tag,renotify,requireInteraction}` and shows them. The amsg-shared package's `ContentPush` / `ToolRequestPush` typedefs have included `notification?` since next.3. diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index a35ccf7..d3ff195 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -539,7 +539,7 @@ async function runLegacyInstant(payload, ctx) { } // Step 2: ContentPush burst. - const messages = splitMessageIntoSentences(messageContent, payload.splitPattern ?? null); + const messages = splitMessageIntoSentences(messageContent); for (let i = 0; i < messages.length; i++) { const contentPush = buildContentPush({ From 19e878b29b5b2c95d8622993d5fa00b8a41b4a7a Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 18:13:56 +0800 Subject: [PATCH 22/33] =?UTF-8?q?docs(amsg-instant):=20generic-ify=20hook?= =?UTF-8?q?=20examples=20=E2=80=94=20drop=20SullyOS-flavored=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback flagged that the next.4 README + migration-guide examples leak SullyOS-specific concepts (sanitize/raw split, business tag names like [[SEND_EMOJI]], LLM personas like 来自 Sully, SullyOS-specific tool names like notion_read_diary, CJK-space-as-line- break heuristic) — readers were getting the impression the library is specifically for Chinese chat-character apps. Replaced with portable shapes: - Motivation prose now talks about "multiple heuristics in one pass" and "inline business tags" without naming a specific tag format. - Example 2 / Recipe 3 uses generic internal markup + a game-master persona ("Quest Master") with English text — the point is "banner ≠ payload" without dragging in CJK / business tags. - Example 3 / Recipe 4 uses generic lookup_user / fetch_weather tool names with English narration. - "metadata.directives" SullyOS-ism replaced with a generic description of per-chunk state (tracking IDs, A/B variants, locale, single-fire side-effect flags). No code change, no API change, no test change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/rei-standard-amsg/instant/README.md | 30 ++++++++-------- .../instant/docs/migration-0.8.0-next.4.md | 36 +++++++++---------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index fce4475..d38b9c8 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -314,10 +314,10 @@ lib 给每个 push 自动补这 3 个机械字段(hook 自己设 `messageId` next.4 起 lib 不再做任何拆分。hook 返 `pushPayloads: PushPayload[]`,里面装的就是 lib 会原样依次发的 N 条 push。常见 caller 会自己实现: -- 按 `\n` 或 CJK 字符之间的空格切(lookbehind / lookahead 写得出来) -- 按 inline tag(比如 `[[SEND_EMOJI: xxx]]`)独立成段 +- 一些 caller 的分句逻辑混合多种启发式:换行、自定义 sentinel、跨语言断句、tag-level 边界等,用单个正则表达力不够 +- 按 inline 业务标签(自定义占位符 / markup 片段)独立成段 - 切完空段 `filter`、按业务规则前后 `merge` / `split` 二阶段 -- per-chunk `notification.body` 用 sanitized 文本,`message` 字段保留 raw +- per-chunk 显示文本和载荷文本可以不一样:`notification.body` 是 OS banner(受长度 / 字符集限制、可能需要纯文本预览),`message` 字段给客户端 app 用完整原文做后处理 如果想要 0.7 / next.2 / next.3 的「默认 `/([。!?!?]+)/` 句切」行为,自己写: @@ -351,12 +351,12 @@ return { messageKind: 'content', sessionId: ctx.sessionId, message: 'Hello', - notification: { title: 'Sully', body: 'Hello' }, + notification: { title: 'Assistant', body: 'Hello' }, }], }; ``` -#### 例 2:3 chunk content + 不同 notification.body(banner 显示 sanitized,bubble 显示 raw) +#### 例 2:3 chunk content + 不同 notification.body(banner 跟 message 显示文本不一样) ```js return { @@ -365,20 +365,20 @@ return { { messageKind: 'content', sessionId: ctx.sessionId, - message: '你看', - notification: { title: '来自 Sully', body: '你看' }, + message: 'Greetings, traveler.', + notification: { title: 'Quest Master', body: 'Greetings, traveler.' }, }, { messageKind: 'content', sessionId: ctx.sessionId, - message: '[[SEND_EMOJI: 笑]]', // raw 给客户端 app - notification: { title: '来自 Sully', body: '[表情:笑]' }, // sanitized 给 banner + message: 'plotting next move', // internal markup for client app + notification: { title: 'Quest Master', body: '…' }, // condensed banner preview }, { messageKind: 'content', sessionId: ctx.sessionId, - message: '我没事的', - notification: { title: '来自 Sully', body: '我没事的' }, + message: 'The path forward is yours to choose.', + notification: { title: 'Quest Master', body: 'The path forward is yours to choose.' }, }, ], }; @@ -393,16 +393,16 @@ return { { messageKind: 'content', sessionId: ctx.sessionId, - message: '让我同时查日记和天气', - notification: { title: '来自 Sully', body: '让我同时查日记和天气' }, + message: 'Let me check both at once.', + notification: { title: 'Assistant', body: 'Let me check both at once.' }, }, { messageKind: 'tool_request', sessionId: ctx.sessionId, message: '', toolCalls: [ - { id: 'rd_1', type: 'function', function: { name: 'notion_read_diary', arguments: '{"date":"2024-05-21"}' } }, - { id: 'ws_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"北京天气"}' } }, + { id: 'tc_1', type: 'function', function: { name: 'lookup_user', arguments: '{"id":"u_42"}' } }, + { id: 'tc_2', type: 'function', function: { name: 'fetch_weather', arguments: '{"city":"Seattle"}' } }, ], // 无 notification → 不弹 OS 横幅 }, diff --git a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md index 4c7cada..5b8f0d5 100644 --- a/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md +++ b/packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md @@ -18,8 +18,8 @@ Two problems made this leak abstractions: A single-pass regex can't: -- Cut on `\n` AND on the boundary between two CJK characters in the same call. -- Pull `[[SEND_EMOJI: xxx]]` inline tags out as their own segment between sentences. +- Combine multiple heuristics in a single pass: newlines, custom sentinels, cross-language sentence boundaries, tag-level breaks. +- Pull inline business tags (custom placeholders / markup fragments) out as their own segments between sentences. - Drop empty segments after the cut and then merge / re-split by business rules. Callers wanted real JS functions to do the split, not regex sources. The framework's "give us a regex" API was strictly less expressive. @@ -28,8 +28,8 @@ Callers wanted real JS functions to do the split, not regex sources. The framewo `splitHookPushPayload` cloned every field besides `message`. But real callers needed per-chunk variation in: -- `notification.body` — OS banner shows sanitized text; `message` keeps raw text for client app post-processing. Banner text and bubble text disagree. -- `metadata.directives` — side-effect markers must appear on exactly one of N pushes (else client replays N times). +- `notification.body` — OS banner has length / character-set limits and may need a stripped-down preview; the `message` field carries the full payload (markup, business tags, codes) for the client app to post-process. Banner text and in-app text legitimately diverge. +- `metadata` — per-chunk state: chunk-specific tracking IDs, A/B test variants, locale, single-fire side-effect markers (e.g., a "play sound" flag that must land on exactly one of N pushes). - `metadata.iteration` — agentic-loop state per chunk. - `messageId` — must be unique per push (SW IDB keyPath; duplicates overwrite). @@ -84,7 +84,7 @@ return { messageKind: 'content', sessionId: ctx.sessionId, message: 'Hello', - notification: { title: 'Sully', body: 'Hello' }, + notification: { title: 'Assistant', body: 'Hello' }, }, }; ``` @@ -97,7 +97,7 @@ return { messageKind: 'content', sessionId: ctx.sessionId, message: 'Hello', - notification: { title: 'Sully', body: 'Hello' }, + notification: { title: 'Assistant', body: 'Hello' }, }], }; ``` @@ -145,7 +145,7 @@ This pattern is verbose by design — caller now sees exactly what the lib was d ### Recipe 3: per-chunk `notification.body` (THE feature this unlocks) -Use case: client-app `message` text contains tags / emoji codes / inline markup. OS banner must show sanitized text. +Use case: client-app `message` text carries markup, business tags, or compact codes; OS banner needs a different display string (shorter, plain-text, or localized). ```js return { @@ -154,26 +154,26 @@ return { { messageKind: 'content', sessionId: ctx.sessionId, - message: '你看', - notification: { title: '来自 Sully', body: '你看' }, + message: 'Greetings, traveler.', + notification: { title: 'Quest Master', body: 'Greetings, traveler.' }, }, { messageKind: 'content', sessionId: ctx.sessionId, - message: '[[SEND_EMOJI: 笑]]', // raw, client renders as emoji - notification: { title: '来自 Sully', body: '[表情:笑]' }, // sanitized for OS banner + message: 'plotting next move', // internal markup, client renders / hides + notification: { title: 'Quest Master', body: '…' }, // condensed banner preview }, { messageKind: 'content', sessionId: ctx.sessionId, - message: '我没事的', - notification: { title: '来自 Sully', body: '我没事的' }, + message: 'The path forward is yours to choose.', + notification: { title: 'Quest Master', body: 'The path forward is yours to choose.' }, }, ], }; ``` -Pre-next.4, this required either dropping inline tags from `message` (losing client semantics) or skipping `notification` entirely (no banner). With per-push `notification`, both audiences get what they want. +Pre-next.4, either `message` had to lose the client-side markup (and the client lost rendering context) or `notification` had to be skipped (no banner). With per-push `notification`, both audiences get what they want. ### Recipe 4: tool-request with mixed kinds @@ -186,16 +186,16 @@ return { { messageKind: 'content', sessionId: ctx.sessionId, - message: '让我同时查日记和天气', - notification: { title: '来自 Sully', body: '让我同时查日记和天气' }, + message: 'Let me check both at once.', + notification: { title: 'Assistant', body: 'Let me check both at once.' }, }, { messageKind: 'tool_request', sessionId: ctx.sessionId, message: '', toolCalls: [ - { id: 'rd_1', type: 'function', function: { name: 'notion_read_diary', arguments: '{"date":"2024-05-21"}' } }, - { id: 'ws_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"北京天气"}' } }, + { id: 'tc_1', type: 'function', function: { name: 'lookup_user', arguments: '{"id":"u_42"}' } }, + { id: 'tc_2', type: 'function', function: { name: 'fetch_weather', arguments: '{"city":"Seattle"}' } }, ], // No notification — client doesn't show a banner for the tool call itself. }, From dbba6a1197ee4e8bdf2aeebdb2b0c2742e9bbb79 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 18:30:33 +0800 Subject: [PATCH 23/33] feat(amsg-instant): tighten messageId contract + drop split helpers + pin partial-failure error code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three post-tag follow-ups to 0.8.0-next.4: - Inline runLegacyInstant's sentence-split pipeline; delete the three module-internal helpers (DEFAULT_SPLIT_REGEX, splitOnceByRegex, splitMessageIntoSentences). Legacy v0.6-compat byte-for-byte preserved. - assertValidDecision now rejects pushPayloads[i].messageId when set to anything other than a non-empty string (catches '', null, 0, objects, etc.). Hook setting messageId = '' was previously preserved on the wire and broke SW IDB keyPath silently. - sendPushesSequentially wraps transport failures with code: 'PUSH_SEND_FAILED' / messageIndex / statusCode (matches runLegacyInstant's existing wrapping). HookError and PayloadTooLargeError keep their own codes to avoid re-classifying shape violations as transport failures. Case 3 of the contract matrix + the agentic-loop partial-failure test now both pin the error code. Tests: 128 → 131, all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../instant/src/message-processor.js | 98 +++++++------------ .../instant/test/agentic-loop.test.mjs | 2 + .../instant/test/pushpayloads-array.test.mjs | 33 +++++++ 3 files changed, 72 insertions(+), 61 deletions(-) diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index d3ff195..1e82356 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -45,65 +45,6 @@ const DEFAULT_BLOB_TTL_SECONDS = 60; const VALID_DECISIONS = new Set(['finish', 'tool-request', 'continue', 'skip-push']); const PUSH_PAYLOAD_BYTE_ENCODER = new TextEncoder(); -const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/; - -function splitOnceByRegex(chunk, regex) { - const out = chunk - .split(regex) - .reduce((acc, part, i, arr) => { - if (i % 2 === 0 && part.trim()) { - const punctuation = arr[i + 1] || ''; - acc.push(part.trim() + punctuation); - } - return acc; - }, []) - .filter(s => s.length > 0); - // No-match fallback: pass the chunk through untouched so a later regex in - // a cascade can still take a swing at it. - return out.length > 0 ? out : [chunk]; -} - -/** - * Split a message into individual sentences for sequential delivery. - * Mirrors amsg-server message-processor.js (do not drift). - * - * `splitPattern` is an optional caller-provided override: - * - `string` → single regex source, used in place of the default - * - `string[]` → applied as a cascade: split by patterns[0], then - * split each resulting chunk by patterns[1], etc. - * - omitted / null / [] / undefined → default /([。!?!?]+)/ - * - * Capture-group convention: if you want the delimiter re-attached to the - * preceding chunk (matches default behavior), wrap your delimiter in `(...)` - * — e.g. `"([\\n]+)"` not `"[\\n]+"`. We don't auto-wrap; that would require - * parsing escaped/character-class/non-capturing groups. - * - * Internal to `runLegacyInstant` — the legacy path still applies the - * default sentence regex to content. The hook path no longer splits - * (Task 4); hooks return the exact pushPayloads they want delivered. - * - * @param {string} messageContent - * @param {string | string[] | null} [splitPattern=null] - * @returns {string[]} - */ -function splitMessageIntoSentences(messageContent, splitPattern = null) { - const sources = - splitPattern == null ? null : - Array.isArray(splitPattern) ? splitPattern : - [splitPattern]; - - const regexes = (sources && sources.length > 0) - ? sources.map(s => new RegExp(s)) - : [DEFAULT_SPLIT_REGEX]; - - let chunks = [messageContent]; - for (const regex of regexes) { - chunks = chunks.flatMap(c => splitOnceByRegex(c, regex)); - } - - return chunks.length > 0 ? chunks : [messageContent]; -} - /** * Deliver `pushPayloads` sequentially via `sendPushWithMaybeBlob`, * spacing `SLEEP_BETWEEN_MESSAGES_MS` (1500 ms) between consecutive @@ -138,7 +79,22 @@ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sle } push.messageIndex = i + 1; push.totalMessages = total; - await sendPushWithMaybeBlob(push, payload, ctx, sessionId); + try { + await sendPushWithMaybeBlob(push, payload, ctx, sessionId); + } catch (err) { + // HookError / PayloadTooLargeError already carry their own .code and + // should propagate unwrapped — those are caller-shape contract + // violations, not transport failures. + if (err && (err.code === 'HOOK_THREW' || err.code === 'PAYLOAD_TOO_LARGE')) { + throw err; + } + const wrapped = new Error(err?.message || 'Web Push delivery failed'); + wrapped.code = 'PUSH_SEND_FAILED'; + wrapped.statusCode = err?.statusCode; + wrapped.messageIndex = i + 1; + wrapped.cause = err; + throw wrapped; + } if (i < total - 1) { await sleep(SLEEP_BETWEEN_MESSAGES_MS); } @@ -539,7 +495,21 @@ async function runLegacyInstant(payload, ctx) { } // Step 2: ContentPush burst. - const messages = splitMessageIntoSentences(messageContent); + // Sentence split — legacy path's v0.6-compat behaviour. The default + // regex matches Chinese full-stop family + ASCII ./!/? clusters; the + // reduce reattaches the matched delimiter to the preceding segment + // (split returns interleaved [segment, delim, segment, delim, ...]). + // No caller knob — the public `splitPattern` field is gone in next.4. + const splitOutput = messageContent + .split(/([。!?!?]+)/) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter((s) => s.length > 0); + // Fallback preserves no-punctuation messages as a single push (matches + // the deleted helper's behaviour when the regex didn't match). + const messages = splitOutput.length > 0 ? splitOutput : [messageContent]; for (let i = 0; i < messages.length; i++) { const contentPush = buildContentPush({ @@ -834,6 +804,12 @@ function assertValidDecision(decision) { if (Object.prototype.hasOwnProperty.call(p, 'splitPattern')) { throw new TypeError(`pushPayloads[${i}].splitPattern is removed in next.4; caller is responsible for splitting`); } + if (Object.prototype.hasOwnProperty.call(p, 'messageId')) { + const id = p.messageId; + if (typeof id !== 'string' || id === '') { + throw new TypeError(`pushPayloads[${i}].messageId must be a non-empty string when set, got ${stringifyForError(id)}`); + } + } } } diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 0140723..aac9798 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -877,6 +877,8 @@ describe('next.4 — pushPayloads happy paths', () => { caught = err; } assert.ok(caught, 'mid-array failure should propagate'); + assert.equal(caught.code, 'PUSH_SEND_FAILED'); + assert.equal(caught.messageIndex, 2); assert.equal(pushIdx, 2, 'second push attempted, third skipped'); assert.equal(events.some(e => e.type === 'final_pushed'), false, 'no final_pushed on partial delivery'); }); diff --git a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs index 90d1db0..73ab29d 100644 --- a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs +++ b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs @@ -160,6 +160,8 @@ describe('3) mid-array throw aborts remaining + no final_pushed', () => { }); } catch (e) { caught = e; } assert.ok(caught); + assert.equal(caught.code, 'PUSH_SEND_FAILED'); + assert.equal(caught.messageIndex, 2); assert.equal(pushIdx, 2); assert.equal(events.some(e => e.type === 'final_pushed'), false); }); @@ -314,3 +316,34 @@ describe('13) reasoning auto-emit precedes hook pushPayloads', () => { assert.equal(pushes[1].message, 'final answer'); }); }); + +// 14) messageId edge cases: empty string / null / non-string +describe('14) messageId must be a non-empty string when set', () => { + it('rejects messageId: "" (empty string)', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'a', messageId: '' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /messageId must be a non-empty string/); + }); + it('rejects messageId: null', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'a', messageId: null }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /messageId must be a non-empty string/); + }); + it('rejects messageId: 42 (non-string)', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'a', messageId: 42 }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /messageId must be a non-empty string/); + }); +}); From a2db93089de01818fe958b1a69b8fa18d2cc2d94 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Thu, 21 May 2026 20:48:51 +0800 Subject: [PATCH 24/33] chore(amsg-instant): sync package-lock with 0.8.0-next.4 bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lockfile drift from the version field update in 1088430 — the lock now matches package.json:version. Pure regenerated artifact, no dependency graph change. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 4890565..9d0b267 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1874,7 +1874,7 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.3", + "version": "0.8.0-next.4", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.3" From 926d2c8c2dcafe7ab3fcce5d1e9cb95f9fde4546 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Fri, 22 May 2026 17:58:51 +0800 Subject: [PATCH 25/33] =?UTF-8?q?fix(amsg-instant):=20validateMessagesArra?= =?UTF-8?q?y=20=E6=94=BE=E5=AE=BD=20OpenAI=20tool-call=20=E5=BD=A2?= =?UTF-8?q?=E6=80=81=20(0.8.0-next.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 非破坏性修复: - assistant + 非空 tool_calls: content 允许空串 / null / 缺省 (符合 OpenAI Chat Completions 协议 — 只发工具调用不带 narration 是合法的). 对 tool_calls 数组 做轻量形状校验 ({ id, type:'function', function:{ name, arguments } }). - tool 消息: content 允许空串 (工具返空结果合法); tool_call_id 强校为必填 字符串 — 这是 OpenAI 协议硬约束, 库之前漏校. - 其他 role (system / user / 无 tool_calls 的 assistant): 维持原校验. ChatMessage typedef 同步: role 收窄为字面量联合; content 加入 null; tool_calls 改为结构化签名; tool_call_id 文档化必填. dist *.d.ts/*.d.cts 由 tsup 从 JSDoc 自动生成. 补 7 条 test (3 accept / 4 reject), 全量 138/138 通过. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../rei-standard-amsg/instant/CHANGELOG.md | 16 +++ .../rei-standard-amsg/instant/package.json | 2 +- .../instant/src/session-context.js | 8 +- .../instant/src/validation.js | 31 ++++++ .../instant/test/handler.test.mjs | 100 ++++++++++++++++++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index a850062..fe36ccd 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.5 — `validateMessagesArray` 放宽 OpenAI tool-call 形态 (pre-release) + +非破坏性修复。`validateMessagesArray` 此前过严,会拒绝合法的 OpenAI 工具调用消息: + +- **`role: 'assistant'` + 非空 `tool_calls`**:`content` 现在允许为 `''` / `null` / 缺省 — 符合 OpenAI Chat Completions 协议(assistant 只发工具调用、没有 narration 是合法的)。同时对 `tool_calls` 数组做轻量形状校验(每条要 `{ id, type:'function', function:{ name, arguments } }`),形状非法时给出明确报错。 +- **`role: 'tool'`**:`content` 允许为空串(工具返空结果合法,如 search 无命中);`tool_call_id` 现在强校验为必填字符串 — 这是 OpenAI 协议的硬约束,库之前漏校。 +- `system` / `user` / 不带 `tool_calls` 的 `assistant`:维持原校验,行为不变。 + +### 类型 + +`ChatMessage` typedef 同步更新:`role` 收窄为字面量联合;`content` 类型加入 `null`;`tool_calls` 改为结构化签名(`{ id, type:'function', function:{ name, arguments } }[]`);`tool_call_id` 文档说明其在 tool 消息上必填。dist `*.d.ts` / `*.d.cts` 由 tsup 从源码 JSDoc 自动生成。 + +### 影响 + +任何之前因 `content: ''` 而 400 的 agentic-loop hook(典型场景:assistant 这一轮只回了 tool_calls 没有 narration,下一轮需要把 hook 内部历史回放给 `/continue`)现在可以直接通过。无需调整既有 hook 代码。 + ## 0.8.0-next.4 — BREAKING: pushPayloads-only hook decision API (pre-release) Install with `npm install @rei-standard/amsg-instant@next`. Pre-release — breaking on purpose. 见 [`docs/migration-0.8.0-next.4.md`](./docs/migration-0.8.0-next.4.md) 完整迁移指南. diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 76ad3bf..cb9b3e3 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.4", + "version": "0.8.0-next.5", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/session-context.js b/packages/rei-standard-amsg/instant/src/session-context.js index b08efa9..b5e9d7c 100644 --- a/packages/rei-standard-amsg/instant/src/session-context.js +++ b/packages/rei-standard-amsg/instant/src/session-context.js @@ -21,10 +21,10 @@ /** * @typedef {Object} ChatMessage - * @property {string} role - One of system / user / assistant / tool / etc. - * @property {string | unknown[]} [content] - * @property {unknown} [tool_calls] - * @property {string} [tool_call_id] + * @property {'system' | 'user' | 'assistant' | 'tool'} role + * @property {string | unknown[] | null} [content] - 文本或多模态数组. 带 tool_calls 的 assistant 消息允许为 null / 空串 / 缺省. + * @property {Array<{ id: string, type: 'function', function: { name: string, arguments: string } }>} [tool_calls] - assistant 发起工具调用时携带. + * @property {string} [tool_call_id] - tool 消息必填, 用于关联到此前的 tool_call. * @property {string} [name] */ diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index 5498b4b..9441ffe 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -59,6 +59,37 @@ function validateMessagesArray(messages) { if (!VALID_MESSAGE_ROLES.has(m.role)) { return `messages[${i}].role 必须是 system / user / assistant / tool 之一`; } + + // OpenAI 协议:assistant 消息在带 tool_calls 时, content 可为 null / 空串 / 缺省. + // 跳过 content 校验, 但仍要求 tool_calls 是非空数组 (否则就是无意义的纯空 assistant). + const isAssistantToolCallCarrier = + m.role === 'assistant' + && Array.isArray(m.tool_calls) + && m.tool_calls.length > 0; + if (isAssistantToolCallCarrier) { + // tool_calls 形状轻量校验 — 不严, 上游 LLM API 会再校一遍. + for (let j = 0; j < m.tool_calls.length; j++) { + const tc = m.tool_calls[j]; + if (!tc || typeof tc !== 'object' || typeof tc.id !== 'string' || !tc.function) { + return `messages[${i}].tool_calls[${j}] 形状非法 (需要 { id, type:'function', function:{ name, arguments } })`; + } + } + continue; + } + + // tool 消息: content 允许空串 (工具返回空结果是合法的, 例如 search 无命中); + // tool_call_id 必填 — 这是 OpenAI 协议的硬约束 (用于关联到此前的 tool_call). + if (m.role === 'tool') { + if (typeof m.content !== 'string' && !Array.isArray(m.content)) { + return `messages[${i}].content (tool) 必须是字符串或数组`; + } + if (typeof m.tool_call_id !== 'string' || !m.tool_call_id) { + return `messages[${i}].tool_call_id 必填 (tool 消息必须关联到一次 tool_call)`; + } + continue; + } + + // system / user / 不带 tool_calls 的 assistant: 老校验. if (typeof m.content === 'string') { if (!m.content) { return `messages[${i}].content 不能是空字符串`; diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index ff4fa04..d9de086 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -159,6 +159,106 @@ describe('validateInstantPayload', () => { assert.equal(validateInstantPayload(p).valid, false); }); + // ── assistant tool_call carrier (OpenAI 协议: 带 tool_calls 时 content 可空) ── + it('accepts assistant + tool_calls + empty content string', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: '搜小红书' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { id: 'call_1', type: 'function', function: { name: 'xhs_browse', arguments: '{}' } }, + ], + }, + { role: 'tool', tool_call_id: 'call_1', content: '{"notes":[]}' }, + ]; + assert.equal(validateInstantPayload(p).valid, true); + }); + + it('accepts assistant + tool_calls + null content', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: 'x' }, + { + role: 'assistant', + content: null, + tool_calls: [ + { id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }, + ], + }, + ]; + assert.equal(validateInstantPayload(p).valid, true); + }); + + it('accepts assistant + tool_calls + missing content key', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: 'x' }, + { + role: 'assistant', + tool_calls: [ + { id: 'c', type: 'function', function: { name: 'f', arguments: '{}' } }, + ], + }, + ]; + assert.equal(validateInstantPayload(p).valid, true); + }); + + it('rejects assistant with empty content AND empty tool_calls', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: 'x' }, + { role: 'assistant', content: '', tool_calls: [] }, + ]; + const r = validateInstantPayload(p); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /不能是空字符串/); + }); + + it('rejects assistant with malformed tool_calls entry', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: 'x' }, + { role: 'assistant', content: '', tool_calls: [{ id: 'c' }] }, // 缺 function + ]; + const r = validateInstantPayload(p); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /tool_calls\[0\] 形状非法/); + }); + + it('accepts tool message with empty-string content (空结果合法)', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: 'x' }, + { + role: 'assistant', + content: '', + tool_calls: [{ id: 'c1', type: 'function', function: { name: 'f', arguments: '{}' } }], + }, + { role: 'tool', tool_call_id: 'c1', content: '' }, + ]; + assert.equal(validateInstantPayload(p).valid, true); + }); + + it('requires tool_call_id on tool messages', () => { + const p = makeValidPayload(); + delete p.completePrompt; + p.messages = [ + { role: 'user', content: 'x' }, + { role: 'tool', content: 'result' }, // 缺 tool_call_id + ]; + const r = validateInstantPayload(p); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /tool_call_id 必填/); + }); + it('accepts optional temperature', () => { assert.equal(validateInstantPayload(makeValidPayload({ temperature: 0.3 })).valid, true); assert.equal(validateInstantPayload(makeValidPayload({ temperature: null })).valid, true); From e43fdff14a50e7d6e101131109ddb4663d2540db Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Sun, 24 May 2026 21:44:32 +0800 Subject: [PATCH 26/33] feat(amsg-instant,amsg-sw)!: unify generic multipart transport --- package-lock.json | 4 +- .../rei-standard-amsg/instant/CHANGELOG.md | 30 ++ packages/rei-standard-amsg/instant/README.md | 170 ++----- .../rei-standard-amsg/instant/package.json | 2 +- .../rei-standard-amsg/instant/src/errors.js | 9 +- .../rei-standard-amsg/instant/src/index.js | 128 +++-- .../instant/src/message-processor.js | 275 ++++++----- .../instant/src/multipart.js | 89 ++++ .../instant/test/agentic-loop.test.mjs | 81 +++- .../instant/test/reasoning-push.test.mjs | 48 +- packages/rei-standard-amsg/sw/CHANGELOG.md | 27 ++ packages/rei-standard-amsg/sw/README.md | 59 ++- packages/rei-standard-amsg/sw/package.json | 2 +- packages/rei-standard-amsg/sw/src/index.js | 456 +++++++++++++++++- .../sw/test/dispatch.test.mjs | 150 ++++++ 15 files changed, 1165 insertions(+), 365 deletions(-) create mode 100644 packages/rei-standard-amsg/instant/src/multipart.js diff --git a/package-lock.json b/package-lock.json index 9d0b267..eeded58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1874,7 +1874,7 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.4", + "version": "0.8.0-next.6", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.3" @@ -1941,7 +1941,7 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.1", + "version": "2.1.0-next.2", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.0" diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index fe36ccd..76ed739 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.6 — BREAKING: generic multipart transport (pre-release) + +next 阶段把 oversized push 的 transport 收敛成一套通用 multipart 协议。旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除;`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,都可以被 `_multipart` 包装。 + +### New + +- **`buildMultipartPushPayloads(payload, { maxChunkBytes?, id?, ttlMs? })`** — 构造 generic `_multipart` Web Push payloads。原始 JSON 先 UTF-8 编码,再按 byte 切片并 base64url 编码,避免 Unicode 边界问题。 +- **`multipart` handler option** — 默认开启。配置项:`enabled`、`maxChunkBytes`、`ttlMs`、`maxChunks`、`maxTotalBytes`。 +- **`multipart_built` / `multipart_sent` events** — 发送端可观测 multipart fallback 何时触发、原始 `messageKind` 是什么、共拆了几片。 + +### Changed + +- `sendPushWithMaybeBlob` 发送优先级现在是: + 1. 小 payload:直接 Web Push。 + 2. oversized + 有 BlobStore:仍优先走 BlobStore envelope。 + 3. oversized + 无 BlobStore + multipart enabled:走 generic `_multipart`。 + 4. oversized + 无 BlobStore + multipart disabled / 超 multipart 上限:抛 `PayloadTooLargeError`。 +- legacy content push、HOOK_THREW diagnostic、LOOP_EXCEEDED diagnostic 现在也走同一个 `sendPushWithMaybeBlob` 路径,因此 oversized payload 策略一致。 +- `reasoningChunkBytes` 保留为 deprecated alias:设置数字时等价于 `multipart.maxChunkBytes`;设置 `null` 且未显式配置 `multipart` 时禁用 generic multipart。它不再产生旧 reasoning chunk fields。 + +### Removed + +- Removed old reasoning-only `chunkIndex` / `totalChunks` wire format from producer output. +- Removed `reasoning_chunked` as the transport signal for oversized reasoning. 迁移到 `multipart_built` / `multipart_sent`。 + +### Migration + +- 应用级 SW 不应再依赖 `chunkIndex` / `totalChunks` 拼 reasoning。请升级 `@rei-standard/amsg-sw` 到支持 generic multipart 的 next 版本,让 SW 透明还原完整 payload。 +- 如果生产环境不想依赖 multipart fallback,继续配置 BlobStore;BlobStore 仍然优先于 multipart。 + ## 0.8.0-next.5 — `validateMessagesArray` 放宽 OpenAI tool-call 形态 (pre-release) 非破坏性修复。`validateMessagesArray` 此前过严,会拒绝合法的 OpenAI 工具调用消息: diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index d38b9c8..c9c33b8 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -38,9 +38,10 @@ npm install @rei-standard/amsg-instant | `onEvent` | function | ❌ | 事件钩子:`request` / `llm_done` / `push_sent` / `error`(明文模式下 `request` 事件不再带 userId —— 如果需要按用户分流日志,从 `payload.contactName` 或 `payload.metadata` 自取) | | `onLLMOutput` | function | ❌ | **0.7.0+**:每轮 LLM 输出后的决策钩子。配了它就进 agentic loop 模式;不配则走 v0.6 老路径(字节级兼容)。见 [Agentic Loop](#agentic-loop070) | | `blobStore` | object | ❌ | **0.7.0+**:可选 blob 后端。push payload UTF-8 字节超过 `maxInlineBytes`(默认 2600)时自动把 body 写进 store、改推 200 B envelope。见 [BlobStore](#blobstore070) | +| `multipart` | object | ❌ | **next+**:通用 multipart transport。超出 inline、且没配 BlobStore 时,任意 JSON-safe payload 都可拆成 `_multipart` 分片。默认 `enabled:true`、`maxChunkBytes:1800`、`ttlMs:60000`、`maxChunks:128`、`maxTotalBytes:256000`。见 [Generic multipart transport](#generic-multipart-transportnext)。 | | `maxLoopIterations` | number | ❌ | **0.7.0+**:单次 worker 调用内 `decision:'continue'` 的硬上限,默认 10。仅防本进程内 hook 反复 continue 失控;跨请求的 `/continue` 洪水攻击由上游 auth/rate-limit 处理 | | `autoEmitReasoning` | boolean | ❌ | **0.8.0+**:默认 `true`。`true` 时框架在调 hook 前自动 emit `ReasoningPush`(如果 LLM 响应带非空 `reasoning_content`)。`false` 把 reasoning emit 完全交给 hook 自己负责(hook 可读 `ctx.llmResponse.choices[0].message.reasoning_content` 并用 `buildReasoningPush` + 自己 dispatch)。legacy 路径忽略此项始终自动 emit。 | -| `reasoningChunkBytes` | number \| null | ❌ | **0.8.0-next.2+**:`ReasoningPush.reasoningContent` 的 UTF-8 字节上限。默认 `2000` — reasoning 超 2 KB 时框架按 codepoint 边界切成 N 份带 `chunkIndex` / `totalChunks` 投递,SW 拼接还原。设 `null` 禁用字节切(超限走 BlobStore 或抛 `PAYLOAD_TOO_LARGE`)。构造期校验范围 `[500, maxInlineBytes - 600]`,不合法抛 `TypeError`。详见 [Reasoning chunking](#reasoning-chunking080-next2)。 | +| `reasoningChunkBytes` | number \| null | ❌ | **Deprecated in next**:旧 reasoning 专用字节切配置。保留为 `multipart.maxChunkBytes` 的兼容别名;`null` 仅在未显式配置 `multipart` 时禁用 generic multipart。不会再产生 `chunkIndex` / `totalChunks` reasoning wire fields。 | ### 鉴权策略 @@ -228,7 +229,7 @@ curl -X POST https://instant.example.com/instant \ | `PUSH_SEND_FAILED` | 502 | 0.1 | Web Push 派送失败 | | `COMPLETE_PROMPT_NOT_SUPPORTED_ON_HOOK_PATH` | 400 | 0.7 | 配了 `onLLMOutput` 之后 `/instant` 或 `/continue` 还传 `completePrompt`;hook 路径只接受 `messages` 数组 | | `HOOK_THREW` | 500 | 0.7 | `onLLMOutput` 抛错或返了非法 decision(`null` / 不识别的 `decision` 值 / `pushPayloads` 不是数组或为空 / 单个 push 不可 JSON-serialize)。同时会推一条诊断 push(payload `{type:'error', code:'HOOK_THREW',...}`) | -| `PAYLOAD_TOO_LARGE` | 500 | 0.7 | hook 返的 `pushPayloads` 中某个 push UTF-8 字节超 `maxInlineBytes` 且没配 `blobStore`。配上 BlobStore 自动走 envelope 转发 | +| `PAYLOAD_TOO_LARGE` | 500 | 0.7 | hook 返的 `pushPayloads` 中某个 push UTF-8 字节超 `maxInlineBytes`,且没有 BlobStore、generic multipart 也被禁用或超过 multipart 上限。配上 BlobStore 会优先走 envelope;没配 BlobStore 时默认走 generic multipart | | `CONTINUE_NOT_AVAILABLE` | 400 | 0.7 | 往没配 `onLLMOutput` 的 handler POST `/continue`。`/continue` 是 agentic loop 的续跑端点,没钩子就没东西可续,直接拒掉避免误报成 `HOOK_THREW` | | `INTERNAL_ERROR` | 500 | 0.1 | 其他未分类内部错误 | @@ -515,65 +516,47 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): --- -## Reasoning chunking(0.8.0-next.2+) +## Generic multipart transport(next) -reasoning-heavy LLM(DeepSeek-R1 / GLM-4.5 / Qwen3-Thinking 等)经常输出 3-10 KB `reasoning_content`,远超 Web Push 单 payload ~2.6 KB 安全线。next.2 内置 transparent 字节切分:framework 在产出 `ReasoningPush` 时自动按 UTF-8 codepoint 边界切成 N 份带 `chunkIndex` / `totalChunks` 投递,SW 拼回完整字符串。**绝大多数 reasoning-heavy 部署不再需要 BlobStore。** +> **next BREAKING**:旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除。`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,超限时都走同一套 generic `_multipart` transport。应用层不应该再监听或拼接 reasoning 半片。 -> **0.8.0-next.4 BREAKING**:`reasoningSplitPattern` 已移除(请求 body 带它直接 400)。auto-emit 路径只剩单层字节切;想要句切,自己在 hook 里切完用 `pushPayloads` 返回。下文「两层 cascade」描述的是 next.2 / next.3 历史行为,next.4 起 Layer 1 永远 OFF(且无法开启)。 +发送优先级很简单: -### 两层 cascade(next.2 / next.3 历史;next.4 仅剩 Layer 2) +1. payload UTF-8 JSON 字节数 `<= maxInlineBytes`:直接 Web Push。 +2. 超限且配置了 `blobStore.adapter`:写 BlobStore,推 `{ _blob:true, key, url, messageKind?, type? }` envelope。 +3. 超限、没有 BlobStore、`multipart.enabled !== false`:推 `_multipart` 分片。 +4. 超限、没有 BlobStore、multipart 禁用或超过上限:抛 `PayloadTooLargeError`。 -``` -reasoningContent - │ - ▼ -Layer 1 — 语义切(reasoningSplitPattern,next.4 已移除) - • 按 regex 切成 M 段,每段带 messageIndex 1..M / totalMessages M - │ - ▼ 对每个 Layer-1 段独立量字节 -Layer 2 — 字节切(reasoningChunkBytes,默认 ON,2000 B) - • 段字节 ≤ 阈值:单 push(不写 chunkIndex / totalChunks) - • 段字节 > 阈值:codepoint 边界切成 N 份,每片带 chunkIndex 1..N / totalChunks N - │ - ▼ -serial dispatch via sendPushWithMaybeBlob - • 同段 Layer-2 chunk 间间隔 100 ms(transport-only) - • Layer-1 段间间隔 1500 ms(typing-bubble UX) -``` - -### 默认配置 = 透明 - -零配置就 work: +### 配置 ```js createInstantHandler({ vapid: { ... }, onLLMOutput: hook, - // reasoningChunkBytes 默认 2000 — 不需要配 + multipart: { + enabled: true, + maxChunkBytes: 1800, + ttlMs: 60_000, + maxChunks: 128, + maxTotalBytes: 256_000, + }, }); ``` -- 短 reasoning(< 2000 B):单 push,wire 跟 next.1 byte-for-byte 一致。 -- 长 reasoning(> 2000 B):自动切分,老 SW 拿到不带 `chunkIndex` 的单 push 走老路径;新 SW 看到 `chunkIndex` / `totalChunks` 走累积拼接。 - -### 显式禁用 byte chunking +`reasoningChunkBytes` 还保留为迁移期别名: ```js createInstantHandler({ vapid: { ... }, - onLLMOutput: hook, - reasoningChunkBytes: null, // 关闭 Layer 2 - blobStore: { adapter: ... }, // 大 reasoning 走 envelope,没配 blobStore 会抛 PAYLOAD_TOO_LARGE + reasoningChunkBytes: 1600, // 等价于 multipart.maxChunkBytes: 1600 }); ``` -`reasoningChunkBytes: null` 关 Layer 2(字节切),等大 reasoning 时走 BlobStore 兜底。 - -> next.2 / next.3 还有 `reasoningSplitPattern` 控制 Layer 1,next.4 已移除。想要 reasoning 句切的 caller,自己在 hook 里切完用 `pushPayloads` 数组返回(lib 不再做语义切)。 +`reasoningChunkBytes: null` 只在没有显式传 `multipart` 时禁用 generic multipart。想要可靠承载超大 payload,更推荐配 BlobStore;它仍然优先于 multipart。 ### Wire format -#### 单 chunk(≤ 阈值,无 Layer 1) — 跟 next.1 完全一致 +原始业务 payload 是完整 JSON,比如: ```json { @@ -583,96 +566,38 @@ createInstantHandler({ "messageId": "msg__iter_0_reasoning", "sessionId": "sess_abc", "timestamp": "2026-05-20T12:00:00Z", - "reasoningContent": "short reasoning…" + "reasoningContent": "long reasoning..." } ``` -#### Pure Layer 2(无句切,大 reasoning) +Web Push 上实际发出的每片是: ```json -// Chunk 1 of 3 { - "messageKind": "reasoning", - "messageId": "msg__iter_0_reasoning_chunk_1", - "sessionId": "sess_abc", - "chunkIndex": 1, - "totalChunks": 3, - "reasoningContent": "first 2000 bytes…" -} -``` - -#### Cascade(Layer 1 + Layer 2) - -```json -// Layer-1 段 2/3,Layer-2 chunk 1/3 -{ - "messageKind": "reasoning", - "messageId": "msg__iter_0_reasoning_chunk_1", - "sessionId": "sess_abc", - "messageIndex": 2, - "totalMessages": 3, - "chunkIndex": 1, - "totalChunks": 3, - "reasoningContent": "first 2000 bytes of sentence 2…" -} -``` - -### SW 端拼接合约 - -```js -// 伪代码 — 在 SW 的 'push' 事件 handler 里 -const buffers = new Map(); // sessionId → { [messageIndex]: { chunks: Map, total: number } } - -function onReasoningPush(p) { - // Single-shot — neither axis present. 直接消费。 - if (p.chunkIndex === undefined && p.messageIndex === undefined) { - return deliverComplete(p.sessionId, p.reasoningContent); - } - - // 按 (sessionId, messageIndex) 分桶 — messageIndex 不存在视作 0。 - const segIdx = p.messageIndex ?? 0; - const segTotal = p.totalMessages ?? 1; - const chunkIdx = p.chunkIndex ?? 1; - const chunkTotal = p.totalChunks ?? 1; - - const bySession = buffers.get(p.sessionId) ?? new Map(); - buffers.set(p.sessionId, bySession); - const seg = bySession.get(segIdx) ?? { chunks: new Map(), total: chunkTotal }; - seg.chunks.set(chunkIdx, p.reasoningContent); - bySession.set(segIdx, seg); - - // 检查所有 segIdx 1..segTotal 都到齐 + 每段 chunks 1..total 都到齐 → 拼接消费。 - if (bySession.size === segTotal && - [...bySession.values()].every(s => s.chunks.size === s.total)) { - const full = [...bySession.entries()] - .sort(([a],[b]) => a - b) - .map(([_, s]) => [...s.chunks.entries()].sort(([a],[b]) => a - b).map(([_, t]) => t).join('')) - .join(''); - deliverComplete(p.sessionId, full); - buffers.delete(p.sessionId); - } + "messageKind": "_multipart", + "multipart": { + "version": 1, + "id": "mp_", + "index": 1, + "total": 4, + "encoding": "json-utf8-base64url", + "originalMessageKind": "reasoning", + "createdAt": 1710000000000, + "ttlMs": 60000 + }, + "chunk": "base64url..." } ``` -**关键不变量**: -- `chunkIndex` / `totalChunks` 仅在 byte 切实际发生(N > 1)时出现,单 chunk 一律省略。 -- `messageIndex` / `totalMessages` 仅在 caller 自己用 `pushPayloads` 数组提交了 M > 1 条 reasoning push 时出现(next.4 起 auto-emit 路径只剩单层字节切,不再加这俩字段)。 -- Web Push 到达顺序**不保证**,SW 必须按 `chunkIndex` 排序。 -- 跨 sessionId 不要混。每个 LLM round 一个 sessionId。 - -### 事件 - -framework 在 Layer 2 实际触发时 fire 一次 `reasoning_chunked`: +`chunk` 是原始 JSON 的 UTF-8 byte slice 再 base64url,不按 JS string 切,所以不会切坏中文或 surrogate pair。SW 收齐后按 `index` 排序拼 bytes,decode JSON,再把恢复出的原始 payload 递归交回普通分发逻辑。 -```js -onEvent: (e) => { - if (e.type === 'reasoning_chunked') { - console.log(`session=${e.sessionId} bytes=${e.totalBytes} chunks=${e.totalChunks} iter=${e.iteration}`); - } -} -``` +### 迁移说明 -Layer 1 单独的句切不 fire 此事件(用户自己配的,可观测性走业务日志)。 +- 应用级 SW 可以删除自定义 reasoning 拼接逻辑。`@rei-standard/amsg-sw` 会透明重组,client 只收到完整 `messageKind: 'reasoning'` payload。 +- 不要再依赖 `chunkIndex` / `totalChunks` 判断 reasoning 是否完整;next 版本不会再发这些字段。 +- `_multipart` 是保留的 transport kind,不触发业务事件、不弹通知。 +- `content` multipart 收齐后照常 `postMessage` + `showNotification`;`tool_request` / `reasoning` / `error` 仍默认只 `postMessage` 不通知。 +- 发送端会 emit `multipart_built` / `multipart_sent` 事件用于可观测性。旧 `reasoning_chunked` 事件不再表示 wire 行为。 --- @@ -701,20 +626,20 @@ agentic loop 模式下 payload 大小分布(经验值): | tool-request push(带本轮 LLM 原文) | 0.8–1.8 KB | 2.5 KB | 3.5 KB | | tool-request + reasoning | 1.5–3 KB | 4 KB(临界) | 5–6 KB(超) | -→ **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。0.8.0-next.2 引入 [reasoning byte chunking](#reasoning-chunking080-next2) 后,reasoning 超限的场景默认自动切分不再依赖 BlobStore;`BlobStore` 主要是 ContentPush / ToolRequestPush 超限的兜底(以及 reasoning byte chunking 被显式关闭时的 fallback):超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, type? }`,SW 端 `GET ${url}` 拿真 body。 +→ **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。next 阶段引入 [generic multipart transport](#generic-multipart-transportnext) 后,没有 BlobStore 时也能透明拆分任意 JSON-safe payload;`BlobStore` 仍是更可靠方案,且优先级高于 multipart:超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, messageKind?, type? }`,SW / client 再按 envelope 约定读取真 body。 ### 何时启用 / 何时跳过 | 场景 | 推荐 | |---|---| | 不配 `onLLMOutput`(v0.6 legacy 路径) | **不需要配** —— 分句拆出来每段都 < 1 KB | -| agentic loop,只用 reasoning,不带长 ContentPush | **不需要配** —— next.2 起 reasoning 自动 byte chunking,2 KB / chunk | +| agentic loop,只用 reasoning,不带长 ContentPush | 可以先不配 —— 默认 generic multipart 会兜底;生产更推荐 BlobStore | | agentic loop,ContentPush 偶尔很长(代码块 / 长答案) | 推荐配 | -| 显式关闭 `reasoningChunkBytes: null` | **强烈推荐** —— 大 reasoning 兜底走 envelope | +| 显式关闭 `multipart` 或 `reasoningChunkBytes: null` | **强烈推荐** —— 大 payload 兜底走 envelope | | 任何 tool-request 流程 | 推荐配(toolCalls + narration 偶尔会撞线) | | 一次推完整 history | **必须配** —— 必超 | -**不配 + 超限的行为**:抛 `PayloadTooLargeError` + emit `payload_too_large`,不静默截断。调用方据此决定要不要上 BlobStore。 +**不配 BlobStore + 超限的行为**:默认走 generic multipart;如果 `multipart.enabled:false` 或超过 `maxTotalBytes` / `maxChunks`,才抛 `PayloadTooLargeError`。调用方据此决定要不要上 BlobStore。 ### 包内自带 6 个 adapter @@ -1034,6 +959,7 @@ await client.sendInstant({ - `normalizeAiApiUrl(apiUrl)` — 0.4.0 新增,幂等地补全 `/v1/chat/completions` - `sendWebPush({ subscription, payload, vapid, ttl?, fetch? })` — 0.3.0 新增,纯 Web Crypto 实现 - `buildVapidJwt({ audience, subject, publicKey, privateKey })` / `verifyVapidJwt(jwt, publicKey)` — 0.3.0 新增 +- `buildMultipartPushPayloads(payload, { maxChunkBytes?, id?, ttlMs? })` — next 新增,构造 generic `_multipart` transport payloads 子路径: diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index cb9b3e3..c098ae5 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.5", + "version": "0.8.0-next.6", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/errors.js b/packages/rei-standard-amsg/instant/src/errors.js index 311af02..6b56562 100644 --- a/packages/rei-standard-amsg/instant/src/errors.js +++ b/packages/rei-standard-amsg/instant/src/errors.js @@ -31,10 +31,11 @@ export class HookError extends Error { } /** - * Thrown when a push payload exceeds `maxInlineBytes` and no blob store - * is configured (or the configured store's `put` failed). Tells the - * caller exactly how big the payload was vs the cap, so they can - * decide whether to shorten the body or wire up a blob adapter. + * Thrown when a push payload exceeds `maxInlineBytes` and cannot be + * delivered through BlobStore or generic multipart (for example, + * multipart is disabled, its limits are exceeded, or BlobStore `put` + * failed). Tells the caller exactly how big the payload was vs the cap, + * so they can decide whether to shorten the body or wire up storage. */ export class PayloadTooLargeError extends Error { /** diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index e0edceb..b2bbd33 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -30,6 +30,12 @@ import { hmacSha256, timingSafeEqualBytes, } from './utils.js'; +import { + DEFAULT_MULTIPART_CHUNK_BYTES, + DEFAULT_MULTIPART_MAX_CHUNKS, + DEFAULT_MULTIPART_MAX_TOTAL_BYTES, + DEFAULT_MULTIPART_TTL_MS, +} from './multipart.js'; const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -111,25 +117,22 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * read `ctx.llmResponse.choices[0].message.reasoning_content` * and produce its own `buildReasoningPush(...)` envelope. * Legacy (non-hook) path always auto-emits regardless. - * @property {number | null} [reasoningChunkBytes=2000] - * - **next.2 transport knob.** Cap on the UTF-8 byte size - * of a single `ReasoningPush.reasoningContent`. When the - * auto-emitted (or hook-returned) reasoning exceeds this - * threshold, the framework slices it at UTF-8 codepoint - * boundaries via `chunkReasoningByUtf8Bytes` and ships N - * ReasoningPushes with `chunkIndex` / `totalChunks` set; - * the SW reassembles by sorting chunks within a - * `(sessionId, messageIndex)` bucket. Default 2000 keeps - * each full push payload (incl. envelope overhead) safely - * under the 2.6 KB Web Push limit without BlobStore. - * Set to `null` to disable byte chunking entirely — - * oversized reasoning then falls back to BlobStore (if - * configured) or throws `PayloadTooLargeError`. - * Layered with `reasoningSplitPattern` (sentence regex, - * request-payload field): sentence-split runs first, then - * oversized sentences cascade-chunk by byte. Throws - * `TypeError` at handler construction when not in - * `[500, maxInlineBytes - 600]` (or `null`). + * @property {Object} [multipart] + * - **next transport knob.** Generic multipart fallback for + * oversized JSON-safe push payloads when no BlobStore is + * configured. Applies to every `messageKind` (including + * reasoning, tool_request, content, error, and custom + * kinds). Defaults to enabled. + * @property {boolean} [multipart.enabled=true] + * @property {number} [multipart.maxChunkBytes=1800] + * @property {number} [multipart.ttlMs=60000] + * @property {number} [multipart.maxChunks=128] + * @property {number} [multipart.maxTotalBytes=256000] + * @property {number | null} [reasoningChunkBytes] + * - Deprecated alias for `multipart.maxChunkBytes`. + * `null` disables generic multipart only when `multipart` + * is not explicitly configured. It no longer produces + * reasoning-only `chunkIndex` / `totalChunks` wire fields. */ /** @@ -169,10 +172,9 @@ export function createInstantHandler(options) { // most hook callers. The legacy path ignores this setting and // always auto-emits. const autoEmitReasoning = options.autoEmitReasoning !== false; - // Eager validation: `reasoningChunkBytes` throws at handler - // construction (not on the first request) so misconfiguration - // surfaces in startup logs / unit tests, not in production traffic. - const reasoningChunkBytes = resolveReasoningChunkBytes(options, blobStore); + // Eager validation keeps transport misconfiguration in startup logs / + // unit tests instead of surprising the first oversized push. + const multipart = resolveMultipartOptions(options); // Validate VAPID shape eagerly so misconfiguration surfaces on the very // first request rather than the first Web Push attempt. @@ -308,7 +310,7 @@ export function createInstantHandler(options) { blobStore, maxLoopIterations, autoEmitReasoning, - reasoningChunkBytes, + multipart, requestUrl: request.url, isResume: isContinue, }); @@ -332,53 +334,40 @@ export function createInstantHandler(options) { }; } -// Defaults pinned at module scope so the validator and the resolver -// agree without a second source of truth. -const DEFAULT_REASONING_CHUNK_BYTES = 2000; -const DEFAULT_MAX_INLINE_BYTES_FOR_OVERHEAD_CHECK = 2600; -const REASONING_CHUNK_BYTES_MIN = 500; -const REASONING_CHUNK_OVERHEAD_MARGIN = 600; +function resolveMultipartOptions(options) { + const hasMultipart = options.multipart !== undefined; + const raw = hasMultipart ? options.multipart : {}; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new TypeError('[amsg-instant] multipart must be a plain object when set'); + } -/** - * Resolve and validate `options.reasoningChunkBytes`. Returns the - * resolved sentinel value to pass into `processInstantMessage`'s ctx: - * - positive integer when chunking is enabled (caller-provided or default 2000) - * - `null` when chunking is explicitly disabled - * - * Throws `TypeError` at handler construction (NOT at request time) so - * deploys with bad config fail fast in their startup logs / CI rather - * than ship a worker that crashes on the first reasoning-heavy LLM - * response. - * - * The acceptable range is `[REASONING_CHUNK_BYTES_MIN, - * maxInlineBytes - REASONING_CHUNK_OVERHEAD_MARGIN]`. The 600 B - * overhead margin reserves space for a chunk's push-payload metadata - * (messageKind / sessionId / messageId / chunkIndex / totalChunks / - * timestamp / contactName / avatarUrl / messageSubtype) so a chunk - * sized exactly at `reasoningChunkBytes` still fits inline. - * - * @param {Object} options - * @param {import('./blob-store/interface.js').BlobStoreConfig | null} blobStore - * @returns {number | null} - */ -function resolveReasoningChunkBytes(options, blobStore) { - const raw = options.reasoningChunkBytes; - if (raw === undefined) return DEFAULT_REASONING_CHUNK_BYTES; - if (raw === null) return null; - const maxInline = (blobStore && Number.isInteger(blobStore.maxInlineBytes) && blobStore.maxInlineBytes > 0) - ? blobStore.maxInlineBytes - : DEFAULT_MAX_INLINE_BYTES_FOR_OVERHEAD_CHECK; - const upperBound = maxInline - REASONING_CHUNK_OVERHEAD_MARGIN; - if ( - !Number.isInteger(raw) || - raw < REASONING_CHUNK_BYTES_MIN || - raw > upperBound - ) { - throw new TypeError( - `[amsg-instant] reasoningChunkBytes must be a positive integer in [${REASONING_CHUNK_BYTES_MIN}, ${upperBound}] (= maxInlineBytes ${maxInline} − ${REASONING_CHUNK_OVERHEAD_MARGIN} overhead margin), or null to disable. Got: ${raw}` - ); + const multipart = /** @type {Record} */ (raw); + let enabled = multipart.enabled !== false; + let maxChunkBytes = multipart.maxChunkBytes; + + if (options.reasoningChunkBytes !== undefined && maxChunkBytes === undefined) { + if (options.reasoningChunkBytes === null) { + if (!hasMultipart) enabled = false; + } else { + maxChunkBytes = options.reasoningChunkBytes; + } + } + + return { + enabled, + maxChunkBytes: resolvePositiveInt(maxChunkBytes, DEFAULT_MULTIPART_CHUNK_BYTES, 'multipart.maxChunkBytes'), + ttlMs: resolvePositiveInt(multipart.ttlMs, DEFAULT_MULTIPART_TTL_MS, 'multipart.ttlMs'), + maxChunks: resolvePositiveInt(multipart.maxChunks, DEFAULT_MULTIPART_MAX_CHUNKS, 'multipart.maxChunks'), + maxTotalBytes: resolvePositiveInt(multipart.maxTotalBytes, DEFAULT_MULTIPART_MAX_TOTAL_BYTES, 'multipart.maxTotalBytes'), + }; +} + +function resolvePositiveInt(value, fallback, fieldName) { + if (value === undefined || value === null) return fallback; + if (!Number.isInteger(value) || value <= 0) { + throw new TypeError(`[amsg-instant] ${fieldName} must be a positive integer. Got: ${value}`); } - return raw; + return value; } /** @@ -616,6 +605,7 @@ export { sendPushWithMaybeBlob, readReasoningContent, } from './message-processor.js'; +export { buildMultipartPushPayloads } from './multipart.js'; export { sendWebPush, buildVapidJwt, verifyVapidJwt } from './webpush.js'; export { HookError, PayloadTooLargeError, LlmCallError, MemoryStoreFullError } from './errors.js'; export { buildSessionContext, extractAssistantMessage } from './session-context.js'; diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 1e82356..108a203 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -19,26 +19,22 @@ import { buildContentPush, buildReasoningPush, buildErrorPush, - chunkReasoningByUtf8Bytes, } from '@rei-standard/amsg-shared'; import { sendWebPush } from './webpush.js'; import { randomUUID } from './utils.js'; import { HookError, LlmCallError, PayloadTooLargeError } from './errors.js'; import { buildSessionContext, extractAssistantMessage } from './session-context.js'; +import { + DEFAULT_MULTIPART_CHUNK_BYTES, + DEFAULT_MULTIPART_MAX_CHUNKS, + DEFAULT_MULTIPART_MAX_TOTAL_BYTES, + DEFAULT_MULTIPART_TTL_MS, + MULTIPART_MESSAGE_KIND, + buildMultipartPushPayloads, +} from './multipart.js'; const SLEEP_BETWEEN_MESSAGES_MS = 1500; -// Inter-chunk spacing for reasoning byte chunks. Byte-chunking is a -// transport-level workaround (Web Push payload limit), NOT a -// typing-bubble UX axis, so the inter-chunk gap is much smaller than -// the inter-sentence gap. 100 ms is enough to avoid pummelling the -// push gateway in a tight loop while keeping perceived latency low. -const SLEEP_BETWEEN_REASONING_CHUNKS_MS = 100; -// Mirrors `DEFAULT_REASONING_CHUNK_BYTES` in `index.js` — kept in sync -// so `processInstantMessage` callers that bypass `createInstantHandler` -// (tests, direct programmatic use) still get the same default. -const DEFAULT_REASONING_CHUNK_BYTES = 2000; - const DEFAULT_MAX_LOOP_ITERATIONS = 10; const DEFAULT_MAX_INLINE_BYTES = 2600; const DEFAULT_BLOB_TTL_SECONDS = 60; @@ -102,98 +98,23 @@ async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sle return total; } -// ─── Reasoning byte chunking ──────────────────────────────────────────── - -/** - * Slice a ReasoningPush into one or more byte-bounded pushes. When - * `reasoningContent` UTF-8 length exceeds `reasoningChunkBytes`, the - * lib chunks at UTF-8 codepoint boundaries via - * `chunkReasoningByUtf8Bytes` and ships each chunk as its own push - * with `chunkIndex` / `totalChunks`. Otherwise ships as one. - * - * `null` for `reasoningChunkBytes` disables chunking entirely — - * oversized reasoning then either flows through `sendPushWithMaybeBlob` - * (and BlobStore) or throws `PayloadTooLargeError`. - * - * `messageId` is regenerated per leaf so each push has a unique id: - * `msg__iter__reasoning_chunk_` - * - * @param {Object} reasoningPush - * @param {number | null | undefined} reasoningChunkBytes - * @param {number | undefined} iteration - Legacy path passes `undefined`; - * the messageId template falls back to `iter_0` in that case. Hook path - * always passes an integer iteration counter. - * @returns {Array} - */ -function sliceReasoningPush(reasoningPush, reasoningChunkBytes, iteration) { - if (reasoningChunkBytes === null) return [reasoningPush]; - // `chunkReasoningByUtf8Bytes` from @rei-standard/amsg-shared requires - // maxBytes >= 4 (UTF-8 max codepoint width); honour that floor here so a - // pathological ctx.reasoningChunkBytes injection falls back to the default - // instead of crashing inside the shared lib. - const threshold = (Number.isInteger(reasoningChunkBytes) && reasoningChunkBytes >= 4) - ? reasoningChunkBytes - : DEFAULT_REASONING_CHUNK_BYTES; - - const text = typeof reasoningPush.reasoningContent === 'string' - ? reasoningPush.reasoningContent - : ''; - if (!text) return [reasoningPush]; - - const byteLen = PUSH_PAYLOAD_BYTE_ENCODER.encode(text).byteLength; - if (byteLen <= threshold) return [reasoningPush]; - - const pieces = chunkReasoningByUtf8Bytes(text, threshold); - const totalChunks = pieces.length; - const iterTag = Number.isInteger(iteration) ? iteration : 0; - return pieces.map((piece, i) => ({ - ...reasoningPush, - messageId: `msg_${randomUUID()}_iter_${iterTag}_reasoning_chunk_${i + 1}`, - reasoningContent: piece, - chunkIndex: i + 1, - totalChunks, - })); -} +// ─── Reasoning emission ───────────────────────────────────────────────── /** - * Ship a ReasoningPush, byte-chunking if oversized. Fires a single - * `reasoning_chunked` event when chunking actually splits the push. - * No event fires when chunking is a no-op (single push) — the event is - * meant to signal Layer-2 byte chunking actually triggered, not just - * normal reasoning emission. - * Serial delivery with `SLEEP_BETWEEN_REASONING_CHUNKS_MS` (100 ms) - * between chunks — byte chunking is a transport-level workaround, not - * a typing-bubble UX axis, so the inter-chunk gap is much smaller than - * the inter-sentence gap. + * Ship a ReasoningPush through the same transport path as every other + * payload. Oversized reasoning no longer uses the old reasoning-only + * `chunkIndex` / `totalChunks` wire format; `sendPushWithMaybeBlob` + * decides direct push, BlobStore envelope, or generic `_multipart`. * * @param {Object} reasoningPush * @param {Object} payload * @param {Object} ctx * @param {string} sessionId - * @param {(ms: number) => Promise} sleep - * @param {number | undefined} iteration * @returns {Promise} Total leaves shipped. */ -async function emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration) { - const leaves = sliceReasoningPush(reasoningPush, ctx.reasoningChunkBytes, iteration); - - if (leaves.length > 1) { - const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; - const totalBytes = typeof reasoningPush.reasoningContent === 'string' - ? PUSH_PAYLOAD_BYTE_ENCODER.encode(reasoningPush.reasoningContent).byteLength - : 0; - const evt = { type: 'reasoning_chunked', sessionId, totalChunks: leaves.length, totalBytes }; - if (Number.isInteger(iteration)) evt.iteration = iteration; - onEvent(evt); - } - - for (let i = 0; i < leaves.length; i++) { - await sendPushWithMaybeBlob(leaves[i], payload, ctx, sessionId); - if (i < leaves.length - 1) { - await sleep(SLEEP_BETWEEN_REASONING_CHUNKS_MS); - } - } - return leaves.length; +async function emitReasoning(reasoningPush, payload, ctx, sessionId) { + await sendPushWithMaybeBlob(reasoningPush, payload, ctx, sessionId); + return 1; } /** @@ -398,6 +319,8 @@ function readReasoningContent(llmResponse) { * invoking the hook — callers wanting reasoning emission must build * it themselves with `buildReasoningPush` and push it via their own * `pushPayload`. + * @param {Object} [ctx.multipart] - Generic multipart transport fallback + * for oversized JSON-safe payloads when BlobStore is not configured. * @returns {Promise} */ export async function processInstantMessage(payload, ctx) { @@ -441,7 +364,6 @@ async function runLegacyInstant(payload, ctx) { throw error; } - const pushSubscription = payload.pushSubscription; const contactName = payload.contactName; const avatarUrl = payload.avatarUrl || null; const messageSubtype = payload.messageSubtype || 'chat'; @@ -470,16 +392,12 @@ async function runLegacyInstant(payload, ctx) { // user-facing content burst. Mirrors the hook path's // `reasoning_push_failed` event (runAgenticLoop). // - // `emitReasoning` byte-chunks via `ctx.reasoningChunkBytes` - // (default 2000 B): short reasoning ships as a single push; - // oversized reasoning is sliced into N chunks at UTF-8 codepoint - // boundaries with `chunkIndex` / `totalChunks`. + // Generic transport handles oversized reasoning now: direct push, + // BlobStore envelope, or `_multipart`, never the old + // reasoning-only `chunkIndex` / `totalChunks` wire format. let reasoningShipped = false; try { - // Legacy path has no "iteration" — pass undefined so messageId - // template falls back to `iter_0` and the `reasoning_chunked` - // event omits the field. - await emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, undefined); + await emitReasoning(reasoningPush, payload, ctx, sessionId); reasoningShipped = true; onEvent({ type: 'reasoning_pushed', sessionId }); } catch (err) { @@ -530,14 +448,10 @@ async function runLegacyInstant(payload, ctx) { }); try { - await sendWebPush({ - subscription: pushSubscription, - payload: JSON.stringify(contentPush), - vapid: ctx.vapid, - fetch: fetchImpl, - }); + await sendPushWithMaybeBlob(contentPush, payload, ctx, sessionId); onEvent({ type: 'push_sent', messageIndex: i + 1, totalMessages: messages.length, sessionId }); } catch (err) { + if (err && err.code === 'PAYLOAD_TOO_LARGE') throw err; const error = new Error(err?.message || 'Web Push delivery failed'); error.code = 'PUSH_SEND_FAILED'; error.statusCode = err?.statusCode; @@ -633,10 +547,7 @@ async function runAgenticLoop(payload, ctx) { metadata: payload.metadata || {}, }); try { - // Byte-chunk via `ctx.reasoningChunkBytes` (default 2000 B): - // short reasoning ships as one push; oversized reasoning is - // sliced into N chunks with `chunkIndex`/`totalChunks`. - await emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration); + await emitReasoning(reasoningPush, payload, ctx, sessionId); onEvent({ type: 'reasoning_pushed', sessionId, iteration }); } catch (err) { // Don't fail the whole turn for a reasoning instrumentation @@ -675,12 +586,7 @@ async function runAgenticLoop(payload, ctx) { timestamp: new Date().toISOString(), }); try { - await sendWebPush({ - subscription: payload.pushSubscription, - payload: JSON.stringify(diagnostic), - vapid: ctx.vapid, - fetch: fetchImpl, - }); + await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); } catch (pushErr) { onEvent({ type: 'diagnostic_push_failed', code: 'HOOK_THREW', sessionId, cause: pushErr }); } @@ -706,10 +612,9 @@ async function runAgenticLoop(payload, ctx) { // Reasoning pushes coming from the hook flow through the same // delivery path. `autoEmitReasoning` (default on) handles the // framework-emitted ReasoningPush that comes from the LLM's - // `reasoning_content` field BEFORE the hook fires; `emitReasoning` - // is a single-layer byte-chunker. Hooks wanting custom reasoning - // chunking slice themselves and pass the pieces as individual - // `pushPayloads` entries. + // `reasoning_content` field BEFORE the hook fires. Oversized + // payloads, reasoning included, are transport-wrapped by generic + // `_multipart` only after normal BlobStore priority is checked. const messagesSent = await sendPushesSequentially( decision.pushPayloads, payload, @@ -880,8 +785,23 @@ async function sendPushWithMaybeBlob(pushPayload, payload, ctx, sessionId) { } if (!ctx.blobStore || !ctx.blobStore.adapter) { - onEvent({ type: 'payload_too_large', byteLen, maxInline, sessionId }); - throw new PayloadTooLargeError(byteLen, maxInline); + const multipart = resolveRuntimeMultipartOptions(ctx); + if (!multipart.enabled) { + onEvent({ type: 'payload_too_large', byteLen, maxInline, sessionId }); + throw new PayloadTooLargeError(byteLen, maxInline); + } + await sendMultipartPushes(pushPayload, { + byteLen, + fetchImpl, + maxInline, + multipart, + onEvent, + payload, + serialized, + sessionId, + vapid: ctx.vapid, + }); + return; } const adapter = ctx.blobStore.adapter; @@ -924,6 +844,109 @@ async function sendPushWithMaybeBlob(pushPayload, payload, ctx, sessionId) { } } +function resolveRuntimeMultipartOptions(ctx) { + const hasMultipart = ctx && ctx.multipart !== undefined; + const raw = hasMultipart ? ctx.multipart : {}; + const config = raw && typeof raw === 'object' ? raw : {}; + let enabled = config.enabled !== false; + let maxChunkBytes = config.maxChunkBytes; + + if (ctx && ctx.reasoningChunkBytes !== undefined && maxChunkBytes === undefined) { + if (ctx.reasoningChunkBytes === null) { + if (!hasMultipart) enabled = false; + } else { + maxChunkBytes = ctx.reasoningChunkBytes; + } + } + + return { + enabled, + maxChunkBytes: positiveIntegerOrDefault(maxChunkBytes, DEFAULT_MULTIPART_CHUNK_BYTES), + ttlMs: positiveIntegerOrDefault(config.ttlMs, DEFAULT_MULTIPART_TTL_MS), + maxChunks: positiveIntegerOrDefault(config.maxChunks, DEFAULT_MULTIPART_MAX_CHUNKS), + maxTotalBytes: positiveIntegerOrDefault(config.maxTotalBytes, DEFAULT_MULTIPART_MAX_TOTAL_BYTES), + }; +} + +async function sendMultipartPushes(pushPayload, args) { + const { + byteLen, + fetchImpl, + maxInline, + multipart, + onEvent, + payload, + serialized, + sessionId, + vapid, + } = args; + const originalMessageKind = getOriginalMessageKind(pushPayload); + + if (originalMessageKind === MULTIPART_MESSAGE_KIND) { + onEvent({ type: 'payload_too_large', byteLen, maxInline, sessionId }); + throw new PayloadTooLargeError(byteLen, maxInline); + } + + if (byteLen > multipart.maxTotalBytes) { + onEvent({ + type: 'multipart_too_large', + byteLen, + maxTotalBytes: multipart.maxTotalBytes, + originalMessageKind, + sessionId, + }); + throw new PayloadTooLargeError(byteLen, maxInline); + } + + const parts = buildMultipartPushPayloads(pushPayload, { + maxChunkBytes: multipart.maxChunkBytes, + serializedPayload: serialized, + ttlMs: multipart.ttlMs, + }); + if (parts.length > multipart.maxChunks) { + onEvent({ + type: 'multipart_too_many_chunks', + byteLen, + maxChunks: multipart.maxChunks, + totalChunks: parts.length, + originalMessageKind, + sessionId, + }); + throw new PayloadTooLargeError(byteLen, maxInline); + } + + const firstPart = /** @type {{ multipart?: { id?: unknown } }} */ (parts[0] || {}); + const id = firstPart.multipart?.id; + onEvent({ + type: 'multipart_built', + id, + byteLen, + totalChunks: parts.length, + originalMessageKind, + sessionId, + }); + + for (const part of parts) { + await sendWebPush({ + subscription: payload.pushSubscription, + payload: JSON.stringify(part), + vapid, + fetch: fetchImpl, + }); + } + onEvent({ type: 'multipart_sent', id, totalChunks: parts.length, originalMessageKind, sessionId }); +} + +function getOriginalMessageKind(pushPayload) { + return pushPayload && typeof pushPayload === 'object' + ? /** @type {{ messageKind?: unknown }} */ (pushPayload).messageKind + : undefined; +} + +function positiveIntegerOrDefault(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} + /** * Derive the absolute `/blob/:key` URL the SW should fetch. * diff --git a/packages/rei-standard-amsg/instant/src/multipart.js b/packages/rei-standard-amsg/instant/src/multipart.js new file mode 100644 index 0000000..fbaf89b --- /dev/null +++ b/packages/rei-standard-amsg/instant/src/multipart.js @@ -0,0 +1,89 @@ +import { bytesToBase64Url, randomUUID, utf8 } from './utils.js'; + +export const MULTIPART_MESSAGE_KIND = '_multipart'; +export const MULTIPART_ENCODING = 'json-utf8-base64url'; + +export const DEFAULT_MULTIPART_CHUNK_BYTES = 1800; +export const DEFAULT_MULTIPART_TTL_MS = 60_000; +export const DEFAULT_MULTIPART_MAX_CHUNKS = 128; +export const DEFAULT_MULTIPART_MAX_TOTAL_BYTES = 256_000; + +/** + * Build generic multipart Web Push payloads for a JSON-safe business payload. + * The original payload is stringified once, encoded as UTF-8 bytes, split by + * byte count, then each byte slice is base64url encoded. The receiver restores + * the exact original JSON bytes before running normal `messageKind` dispatch. + * + * @param {unknown} payload + * @param {Object} [options] + * @param {number} [options.maxChunkBytes] + * @param {string} [options.id] + * @param {number} [options.ttlMs] + * @param {string} [options.serializedPayload] - Already JSON-stringified payload. + * @returns {Array>} + */ +export function buildMultipartPushPayloads(payload, options = {}) { + const maxChunkBytes = resolvePositiveInteger( + options.maxChunkBytes, + DEFAULT_MULTIPART_CHUNK_BYTES, + 'maxChunkBytes' + ); + const ttlMs = resolvePositiveInteger(options.ttlMs, DEFAULT_MULTIPART_TTL_MS, 'ttlMs'); + const id = typeof options.id === 'string' && options.id.trim() + ? options.id.trim() + : `mp_${randomUUID()}`; + + let serialized = typeof options.serializedPayload === 'string' + ? options.serializedPayload + : undefined; + if (serialized === undefined) { + try { + serialized = JSON.stringify(payload); + } catch (error) { + throw new TypeError(`buildMultipartPushPayloads: payload is not JSON-serializable: ${error?.message ?? error}`); + } + } + if (typeof serialized !== 'string') { + throw new TypeError('buildMultipartPushPayloads: payload serialized to a non-string'); + } + + const bytes = utf8(serialized); + const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes)); + const createdAt = Date.now(); + const originalMessageKind = payload && typeof payload === 'object' + ? /** @type {{ messageKind?: unknown }} */ (payload).messageKind + : undefined; + + /** @type {Array>} */ + const parts = []; + for (let i = 0; i < total; i++) { + const start = i * maxChunkBytes; + const end = Math.min(start + maxChunkBytes, bytes.byteLength); + const chunkBytes = bytes.subarray(start, end); + parts.push({ + messageKind: MULTIPART_MESSAGE_KIND, + multipart: { + version: 1, + id, + index: i + 1, + total, + encoding: MULTIPART_ENCODING, + originalMessageKind: typeof originalMessageKind === 'string' + ? originalMessageKind + : null, + createdAt, + ttlMs, + }, + chunk: bytesToBase64Url(chunkBytes), + }); + } + return parts; +} + +function resolvePositiveInteger(value, fallback, fieldName) { + if (value === undefined || value === null) return fallback; + if (!Number.isInteger(value) || value <= 0) { + throw new TypeError(`buildMultipartPushPayloads: ${fieldName} must be a positive integer`); + } + return value; +} diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index aac9798..1dc673e 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -36,6 +36,7 @@ import { generateTestSubscription, createFetchRouter, decryptCapturedPushBody, + base64UrlToBytes, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -81,6 +82,27 @@ function makeRequest(url, body, headers = {}) { }); } +async function decryptAll(pushCalls) { + const out = []; + for (const call of pushCalls) { + out.push(JSON.parse(await decryptCapturedPushBody(call.body, subKit))); + } + return out; +} + +function restoreMultipartPayload(parts) { + const sorted = parts.slice().sort((a, b) => a.multipart.index - b.multipart.index); + const byteChunks = sorted.map((part) => base64UrlToBytes(part.chunk)); + const totalBytes = byteChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0); + const joined = new Uint8Array(totalBytes); + let offset = 0; + for (const bytes of byteChunks) { + joined.set(bytes, offset); + offset += bytes.byteLength; + } + return JSON.parse(new TextDecoder().decode(joined)); +} + // ─── decision: finish ─────────────────────────────────────────────────── describe('agentic loop — decision: finish', () => { @@ -412,7 +434,43 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { assert.equal(JSON.parse(decrypted)._blob, true); }); - it('no blobStore + oversize payload → PayloadTooLargeError', async () => { + it('no blobStore + multipart enabled → generic multipart fallback', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('x'), + }); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (e) => events.push(e), + onLLMOutput: () => ({ + decision: 'tool-request', + pushPayloads: [{ + messageKind: 'tool_request', + message: 'need a large tool request', + toolCalls: [{ + id: 'call_big', + type: 'function', + function: { name: 'bulk', arguments: JSON.stringify({ data: 'a'.repeat(5000) }) }, + }], + }], + }), + }); + const res = await handler(makeRequest('http://h/instant', basePayload())); + assert.equal(res.status, 200); + + const decoded = await decryptAll(router.pushCalls); + assert.ok(decoded.length > 1); + assert.equal(decoded.every((p) => p.messageKind === '_multipart'), true); + assert.equal(decoded.every((p) => p.multipart.originalMessageKind === 'tool_request'), true); + const restored = restoreMultipartPayload(decoded); + assert.equal(restored.messageKind, 'tool_request'); + assert.equal(restored.toolCalls[0].function.name, 'bulk'); + assert.equal(events.some((e) => e.type === 'multipart_built' && e.originalMessageKind === 'tool_request'), true); + }); + + it('no blobStore + multipart disabled + oversize payload → PayloadTooLargeError', async () => { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: () => makeLlmResponse('x'), @@ -422,6 +480,7 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { vapid, fetch: router.fetch, onEvent: (e) => events.push(e), + multipart: { enabled: false }, onLLMOutput: () => ({ decision: 'finish', pushPayloads: [{ type: 'big', p: 'a'.repeat(5000) }], @@ -433,6 +492,26 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { assert.equal(body.error.code, 'PAYLOAD_TOO_LARGE'); assert.ok(events.find((e) => e.type === 'payload_too_large')); }); + + it('deprecated reasoningChunkBytes:null disables generic multipart when multipart is not explicit', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('x'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + reasoningChunkBytes: null, + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [{ type: 'big', p: 'a'.repeat(5000) }], + }), + }); + const res = await handler(makeRequest('http://h/instant', basePayload())); + assert.equal(res.status, 500); + const body = await res.json(); + assert.equal(body.error.code, 'PAYLOAD_TOO_LARGE'); + }); }); // ─── /blob/:key endpoint ──────────────────────────────────────────────── diff --git a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs index dcdfede..5fdfd97 100644 --- a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs +++ b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs @@ -16,6 +16,7 @@ import { createFetchRouter, decryptCapturedPushBody, makeLlmResponse, + base64UrlToBytes, } from './helpers.mjs'; const LLM_URL = 'https://api.example.com/v1/chat/completions'; @@ -56,6 +57,19 @@ async function decryptAll(pushCalls) { return out; } +function restoreMultipartPayload(parts) { + const sorted = parts.slice().sort((a, b) => a.multipart.index - b.multipart.index); + const byteChunks = sorted.map((part) => base64UrlToBytes(part.chunk)); + const totalBytes = byteChunks.reduce((sum, bytes) => sum + bytes.byteLength, 0); + const joined = new Uint8Array(totalBytes); + let offset = 0; + for (const bytes of byteChunks) { + joined.set(bytes, offset); + offset += bytes.byteLength; + } + return JSON.parse(new TextDecoder().decode(joined)); +} + // ─── Legacy path ──────────────────────────────────────────────────────── describe('legacy path — ReasoningPush auto-emission', () => { @@ -244,9 +258,9 @@ describe('hook path — ReasoningPush auto-emission', () => { }); }); -// ─── next.4 — reasoning byte-chunking simplified ─────────────────────── +// ─── next — generic multipart transport ──────────────────────────────── -describe('next.4 — reasoning byte-chunking simplified', () => { +describe('next — generic multipart transport', () => { it('short reasoning ships as a single push (no chunkIndex on wire)', async () => { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, @@ -263,8 +277,8 @@ describe('next.4 — reasoning byte-chunking simplified', () => { assert.equal(r.reasoningContent, 'short thought'); }); - it('oversized reasoning gets byte-chunked into N pushes with chunkIndex/totalChunks', async () => { - const big = 'x'.repeat(5500); // > default 2000 B threshold + it('oversized reasoning uses generic _multipart and restores the original ReasoningPush', async () => { + const big = 'x'.repeat(5500); const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: () => makeLlmResponse('hi.', { reasoning_content: big }), @@ -277,18 +291,20 @@ describe('next.4 — reasoning byte-chunking simplified', () => { }); await handler(makeRequest(basePayload())); const decoded = await decryptAll(router.pushCalls); - const reasoning = decoded.filter(p => p.messageKind === 'reasoning'); - assert.ok(reasoning.length >= 3, `expected >= 3 chunks for 5500B reasoning at 2000B threshold, got ${reasoning.length}`); - for (let i = 0; i < reasoning.length; i++) { - assert.equal(reasoning[i].chunkIndex, i + 1); - assert.equal(reasoning[i].totalChunks, reasoning.length); + const multipart = decoded.filter(p => p.messageKind === '_multipart'); + assert.ok(multipart.length >= 3, `expected >= 3 multipart chunks, got ${multipart.length}`); + for (const part of multipart) { + assert.equal(part.multipart.version, 1); + assert.equal(part.multipart.encoding, 'json-utf8-base64url'); + assert.equal(part.multipart.originalMessageKind, 'reasoning'); + assert.equal(typeof part.chunk, 'string'); } - // Reassembling yields the original - const reassembled = reasoning.map(p => p.reasoningContent).join(''); - assert.equal(reassembled, big); - // reasoning_chunked event fires exactly once - const chunkedEvts = events.filter(e => e.type === 'reasoning_chunked'); - assert.equal(chunkedEvts.length, 1); - assert.equal(chunkedEvts[0].totalChunks, reasoning.length); + const restored = restoreMultipartPayload(multipart); + assert.equal(restored.messageKind, 'reasoning'); + assert.equal(restored.reasoningContent, big); + assert.equal('chunkIndex' in restored, false); + assert.equal('totalChunks' in restored, false); + assert.equal(events.some(e => e.type === 'multipart_built' && e.originalMessageKind === 'reasoning'), true); + assert.equal(events.some(e => e.type === 'reasoning_chunked'), false); }); }); diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index 5fb522e..ec00bec 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog — @rei-standard/amsg-sw +## 2.1.0-next.2 — BREAKING: generic multipart reassembly (pre-release) + +next 阶段统一 multipart transport。SW 现在识别 `messageKind: "_multipart"` 的运输层分片,透明还原原始 payload 后再按原始 `messageKind` 走现有分发和通知策略。 + +### New + +- **`installReiSW(self, { multipart })`** — 新增 multipart 配置: + - `enabled`(默认 `true`) + - `ttlMs`(默认 `60_000`) + - `maxTotalBytes`(默认 `256_000`) + - `maxChunks`(默认 `128`) + - `cleanupIntervalMs`(默认 `15 * 60_000`) +- **IndexedDB-backed pending multipart store** — 支持乱序、重复分片和 SW 重启恢复。 +- **短期 done marker** — 收齐并投递后写 done 标记,避免 push service 重投递最后一片导致重复业务事件。 +- **`REI_SW_EVENT.MULTIPART_EXPIRED`** — TTL 到期仍缺片时广播 `rei-amsg-multipart-expired`,payload 为 `{ id, received, total, originalMessageKind }`。 + +### Changed + +- `_multipart` 是 transport layer,不会触发业务事件,也不会 `showNotification`。 +- multipart 收齐后恢复成原始 JSON payload,再递归进入普通 dispatch。应用层只会看到完整的 `content` / `reasoning` / `tool_request` / `error` / 自定义 kind payload。 +- `content` multipart 收齐后照常 `postMessage` + `showNotification`;`reasoning` / `tool_request` / `error` 仍默认不通知。 + +### Migration + +- 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑。 +- 旧 reasoning chunk wire format 不再由 `@rei-standard/amsg-instant` next 版本发送;接收 oversized reasoning 需要本版本的 generic multipart 支持。 + ## 2.1.0-next.1 — 标题 fallback 至 `来自 {contactName}` (pre-release) Cherry-pick stable `2.0.2` 的标题 fallback 修复到 next 预发布线。`createNotificationFromPayload` 的标题链从 diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index ff1b306..792fd0f 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -20,6 +20,7 @@ | `REI_SW_EVENT.REASONING_RECEIVED` | `'rei-amsg-reasoning-received'` | `payload.messageKind === 'reasoning'` | | `REI_SW_EVENT.TOOL_REQUEST_RECEIVED` | `'rei-amsg-tool-request-received'` | `payload.messageKind === 'tool_request'` | | `REI_SW_EVENT.ERROR_RECEIVED` | `'rei-amsg-error-received'` | `payload.messageKind === 'error'` | +| `REI_SW_EVENT.MULTIPART_EXPIRED` | `'rei-amsg-multipart-expired'` | `_multipart` 分片 TTL 到期仍未收齐 | | `REI_SW_EVENT.UNKNOWN_RECEIVED` | `'rei-amsg-unknown-received'` | 缺 `messageKind`(2.0.x 老 payload / blob envelope) | ### 客户端订阅示例 @@ -32,6 +33,7 @@ navigator.serviceWorker.addEventListener('message', (e) => { case 'rei-amsg-reasoning-received': /* 渲染思考中 UI */ break; case 'rei-amsg-tool-request-received': /* 弹出工具执行确认 */ break; case 'rei-amsg-error-received': /* 显示错误 toast */ break; + case 'rei-amsg-multipart-expired': /* 观测 transport 缺片 */ break; case 'rei-amsg-unknown-received': /* 2.0.x 老 payload 的兼容路径 */ break; } }); @@ -41,15 +43,68 @@ navigator.serviceWorker.addEventListener('message', (e) => { 当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。 +### Generic multipart transport(next) + +next 阶段移除了旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format。现在 `_multipart` 是统一 transport kind,任何原始 payload 都可以被包起来: + +```json +{ + "messageKind": "_multipart", + "multipart": { + "version": 1, + "id": "mp_", + "index": 1, + "total": 4, + "encoding": "json-utf8-base64url", + "originalMessageKind": "reasoning", + "createdAt": 1710000000000, + "ttlMs": 60000 + }, + "chunk": "base64url..." +} +``` + +SW 收到 `_multipart` 后会先写 IndexedDB,支持乱序、重复分片和 SW 重启恢复。未收齐时不 `postMessage`、不 `showNotification`。收齐后按 `index` 拼回原始 JSON payload,删除 pending,写短期 done 标记避免推送服务重投递造成二次业务事件,然后递归走普通 `messageKind` 分发。 + +配置: + +```js +installReiSW(self, { + defaultIcon: '/icon-192x192.png', + defaultBadge: '/badge-72x72.png', + multipart: { + enabled: true, + ttlMs: 60_000, + maxTotalBytes: 256_000, + maxChunks: 128, + cleanupIntervalMs: 15 * 60_000 + } +}); +``` + +TTL 到期仍未收齐时,SW 会清理 pending 并广播: + +```js +{ + type: 'REI_AMSG_PUSH', + event: 'rei-amsg-multipart-expired', + payload: { id, received, total, originalMessageKind } +} +``` + +业务应用只订阅普通事件即可。`content` multipart 收齐后照常弹通知;`reasoning` / `tool_request` / `error` 仍默认不弹通知。 + ### 升级注意事项 - 想给 `reasoning` / `tool_request` / `error` 也弹通知的业务:必须自行在 app 内监听上面的 postMessage 事件、调 `Notification` 或 `registration.showNotification`。SW 默认不再为它们弹通知。 +- 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑;next 版本只会把完整还原后的 reasoning payload 发给 client。 - 客户端代码继续兼容只有 `installReiSW` + `REI_SW_MESSAGE_TYPE`(队列)的 2.0.x 写法——新增导出不破坏既有 API。 - 想拿到 push 类型相关的 TS 类型:从 `@rei-standard/amsg-shared` 引 `AmsgPush` 等类型(本包通过 JSDoc 引用同一份类型)。 ## 功能概览 - 处理 `push` 事件:按 `messageKind` 三轴 schema 分发到客户端 + 仅 `content` 走 `showNotification` +- 透明重组 `_multipart` transport:应用层只收到完整原始 payload - 处理 `message` 事件:支持离线请求入队与主动冲刷队列 - 处理 `sync` 事件:在网络恢复后自动重试队列请求 - 使用 IndexedDB 存储待发送请求,避免页面关闭后丢失 @@ -69,7 +124,8 @@ import { installReiSW } from '@rei-standard/amsg-sw'; installReiSW(self, { defaultIcon: '/icon-192x192.png', - defaultBadge: '/badge-72x72.png' + defaultBadge: '/badge-72x72.png', + multipart: { enabled: true } }); // 业务侧自行实现点击跳转 @@ -151,6 +207,7 @@ export async function enqueueRequestToSW(requestPayload) { - `REASONING_RECEIVED` - `TOOL_REQUEST_RECEIVED` - `ERROR_RECEIVED` +- `MULTIPART_EXPIRED` - `UNKNOWN_RECEIVED` `REI_SW_MESSAGE_TYPE` 包含: diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index e8277e7..c758c9a 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.1", + "version": "2.1.0-next.2", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 8084b27..6455482 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -8,6 +8,9 @@ * - Notification rendering for `messageKind: 'content'` (and legacy * payloads without `messageKind`, for back-compat with 2.0.x * producers). + * - Generic `_multipart` transport reassembly. Multipart chunks are + * stored below the business layer and never dispatched until the + * original payload has been fully restored. * - Offline request queueing and retry with Background Sync. * * Notes: @@ -19,6 +22,8 @@ * - Blob envelopes (`{ _blob: true, key, url, messageKind? }`) are * dispatched to clients verbatim. The SW never auto-fetches the * blob body — that's the client's job. + * - Multipart is different: it is a transparent transport fallback. + * Apps see only the restored original payload. * * Usage (inside your sw.js): * import { installReiSW, REI_SW_EVENT, REI_SW_MESSAGE_TYPE } from '@rei-standard/amsg-sw'; @@ -32,6 +37,7 @@ * case REI_SW_EVENT.REASONING_RECEIVED: // render thinking UI * case REI_SW_EVENT.TOOL_REQUEST_RECEIVED: // prompt tool exec * case REI_SW_EVENT.ERROR_RECEIVED: // show error toast + * case REI_SW_EVENT.MULTIPART_EXPIRED: // observe incomplete transport * case REI_SW_EVENT.UNKNOWN_RECEIVED: // legacy 2.0.x payload * } * }); @@ -47,8 +53,21 @@ const REI_SW_DB_NAME = 'rei-sw'; const REI_SW_DB_STORE = 'request-outbox'; -const REI_SW_DB_VERSION = 1; +const REI_SW_MULTIPART_STORE = 'multipart-pending'; +const REI_SW_MULTIPART_DONE_STORE = 'multipart-done'; +const REI_SW_DB_VERSION = 2; const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox'; +const MULTIPART_MESSAGE_KIND = '_multipart'; +const MULTIPART_ENCODING = 'json-utf8-base64url'; +const DEFAULT_MULTIPART_OPTIONS = Object.freeze({ + enabled: true, + ttlMs: 60_000, + maxTotalBytes: 256_000, + maxChunks: 128, + cleanupIntervalMs: 15 * 60_000, +}); +const memoryMultipartPending = new Map(); +const memoryMultipartDone = new Map(); /** * Wire-level message type for SW → client postMessage envelopes. @@ -72,6 +91,7 @@ export const REI_SW_EVENT = Object.freeze({ REASONING_RECEIVED: 'rei-amsg-reasoning-received', TOOL_REQUEST_RECEIVED: 'rei-amsg-tool-request-received', ERROR_RECEIVED: 'rei-amsg-error-received', + MULTIPART_EXPIRED: 'rei-amsg-multipart-expired', UNKNOWN_RECEIVED: 'rei-amsg-unknown-received' }); @@ -85,6 +105,12 @@ export const REI_SW_MESSAGE_TYPE = Object.freeze({ * @typedef {Object} ReiSWOptions * @property {string} [defaultIcon] - Fallback notification icon URL. * @property {string} [defaultBadge] - Fallback notification badge URL. + * @property {Object} [multipart] + * @property {boolean} [multipart.enabled=true] + * @property {number} [multipart.ttlMs=60000] + * @property {number} [multipart.maxTotalBytes=256000] + * @property {number} [multipart.maxChunks=128] + * @property {number} [multipart.cleanupIntervalMs=900000] */ /** @@ -96,30 +122,20 @@ export const REI_SW_MESSAGE_TYPE = Object.freeze({ export function installReiSW(sw, opts = {}) { const defaultIcon = opts.defaultIcon || '/icon-192x192.png'; const defaultBadge = opts.defaultBadge || '/badge-72x72.png'; + const multipart = normalizeMultipartOptions(opts.multipart); + let lastMultipartCleanupAt = 0; sw.addEventListener('push', (event) => { const payload = readPushPayload(event); if (!payload) return; - const eventName = resolveEventName(payload); - const shouldRenderNotification = isNotificationKind(payload); - - /** @type {Array>} */ - const work = [dispatchPushToClients(sw, eventName, payload)]; - - if (shouldRenderNotification) { - const notification = createNotificationFromPayload(payload, { - defaultIcon, - defaultBadge - }); - if (notification) { - work.push( - sw.registration.showNotification(notification.title, notification.options) - ); - } - } - - event.waitUntil(Promise.all(work)); + event.waitUntil(handlePushPayload(sw, payload, { + defaultBadge, + defaultIcon, + multipart, + getLastMultipartCleanupAt: () => lastMultipartCleanupAt, + setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; }, + })); }); sw.addEventListener('message', (event) => { @@ -144,6 +160,42 @@ export function installReiSW(sw, opts = {}) { }); } +async function handlePushPayload(sw, payload, ctx) { + await maybeCleanupMultipart(sw, ctx); + + if (isMultipartPush(payload)) { + if (!ctx.multipart.enabled) return; + const restoredPayload = await acceptMultipartChunk(sw, payload, ctx.multipart); + if (!restoredPayload) return; + await handlePushPayload(sw, restoredPayload, ctx); + return; + } + + await dispatchBusinessPayload(sw, payload, { + defaultIcon: ctx.defaultIcon, + defaultBadge: ctx.defaultBadge, + }); +} + +async function dispatchBusinessPayload(sw, payload, defaults) { + const eventName = resolveEventName(payload); + const shouldRenderNotification = isNotificationKind(payload); + + /** @type {Array>} */ + const work = [dispatchPushToClients(sw, eventName, payload)]; + + if (shouldRenderNotification) { + const notification = createNotificationFromPayload(payload, defaults); + if (notification) { + work.push( + sw.registration.showNotification(notification.title, notification.options) + ); + } + } + + await Promise.all(work); +} + /** * Map a parsed push payload to its corresponding per-kind event name. * Falls back to `UNKNOWN_RECEIVED` for legacy 2.0.x payloads and blob @@ -282,6 +334,247 @@ function createNotificationFromPayload(payload, defaults) { }; } +function normalizeMultipartOptions(input) { + const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {}; + return { + enabled: source.enabled !== false, + ttlMs: positiveIntegerOrDefault(source.ttlMs, DEFAULT_MULTIPART_OPTIONS.ttlMs), + maxTotalBytes: positiveIntegerOrDefault( + source.maxTotalBytes, + DEFAULT_MULTIPART_OPTIONS.maxTotalBytes + ), + maxChunks: positiveIntegerOrDefault(source.maxChunks, DEFAULT_MULTIPART_OPTIONS.maxChunks), + cleanupIntervalMs: source.cleanupIntervalMs === 0 + ? 0 + : positiveIntegerOrDefault( + source.cleanupIntervalMs, + DEFAULT_MULTIPART_OPTIONS.cleanupIntervalMs + ), + }; +} + +function positiveIntegerOrDefault(value, fallback) { + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function isMultipartPush(payload) { + return !!payload && + typeof payload === 'object' && + payload.messageKind === MULTIPART_MESSAGE_KIND && + payload.multipart && + typeof payload.multipart === 'object' && + typeof payload.chunk === 'string'; +} + +async function acceptMultipartChunk(sw, payload, options) { + // State machine: + // 1. Validate the transport envelope and reject expired chunks before storage. + // 2. Drop already-completed multipart ids using the short-lived done marker. + // 3. Expire any stale pending record for this id before accepting a new one. + // 4. Store only new chunk indexes, track total received bytes, and wait. + // 5. Once all indexes are present, restore original JSON and mark done. + const normalized = normalizeMultipartChunk(payload, options); + if (!normalized) return null; + if (normalized.expiresAt <= Date.now()) { + await dispatchMultipartExpired(sw, { + id: normalized.id, + chunks: {}, + total: normalized.total, + originalMessageKind: normalized.originalMessageKind, + }); + return null; + } + + const done = await readMultipartDone(normalized.id); + if (done && done.expiresAt > Date.now()) return null; + if (done) await deleteMultipartDone(normalized.id); + + const now = Date.now(); + const existing = await readMultipartPending(normalized.id); + if (existing && existing.expiresAt <= now) { + await deleteMultipartPending(existing.id); + await dispatchMultipartExpired(sw, existing); + } + + const base = existing && existing.expiresAt > now + ? existing + : { + id: normalized.id, + createdAt: normalized.createdAt, + expiresAt: normalized.expiresAt, + ttlMs: normalized.ttlMs, + total: normalized.total, + originalMessageKind: normalized.originalMessageKind, + encoding: normalized.encoding, + chunks: {}, + receivedBytes: 0, + }; + + if (base.total !== normalized.total || base.encoding !== normalized.encoding) { + await deleteMultipartPending(normalized.id); + return null; + } + + if (base.chunks[String(normalized.index)] !== undefined) { + return null; + } + + base.chunks[String(normalized.index)] = normalized.chunk; + base.receivedBytes = positiveIntegerOrDefault(base.receivedBytes, 0) + + normalized.chunkBytes.byteLength; + if (base.receivedBytes > options.maxTotalBytes) { + await deleteMultipartPending(normalized.id); + return null; + } + + const received = Object.keys(base.chunks).length; + if (received < base.total) { + await writeMultipartPending(base); + return null; + } + + await deleteMultipartPending(base.id); + let restored; + try { + restored = restoreMultipartPayload(base, options); + } catch (_error) { + return null; + } + // Keep the done marker longer than the pending TTL so push-service + // redelivery cannot trigger a second business event after completion. + const doneTtlMs = Math.max(base.ttlMs * 2, base.ttlMs + 1); + await writeMultipartDone({ + id: base.id, + expiresAt: Date.now() + doneTtlMs, + }); + return restored; +} + +function normalizeMultipartChunk(payload, options) { + const meta = payload.multipart; + if (!meta || typeof meta !== 'object') return null; + if (meta.version !== 1 || meta.encoding !== MULTIPART_ENCODING) return null; + if (typeof meta.id !== 'string' || !meta.id) return null; + if (!Number.isInteger(meta.index) || !Number.isInteger(meta.total)) return null; + if (meta.total <= 0 || meta.total > options.maxChunks) return null; + if (meta.index <= 0 || meta.index > meta.total) return null; + + let chunkBytes; + try { + chunkBytes = base64UrlToBytes(payload.chunk); + } catch (_error) { + return null; + } + + const now = Date.now(); + const ttlMs = Math.min( + positiveIntegerOrDefault(meta.ttlMs, options.ttlMs), + options.ttlMs + ); + const createdAt = Number.isFinite(meta.createdAt) ? Number(meta.createdAt) : now; + const expiresAt = createdAt + ttlMs; + + return { + id: meta.id, + createdAt, + expiresAt, + ttlMs, + total: meta.total, + index: meta.index, + originalMessageKind: typeof meta.originalMessageKind === 'string' + ? meta.originalMessageKind + : null, + encoding: meta.encoding, + chunk: payload.chunk, + chunkBytes, + }; +} + +function restoreMultipartPayload(record, options) { + /** @type {Uint8Array[]} */ + const chunks = []; + let totalBytes = 0; + for (let index = 1; index <= record.total; index++) { + const chunk = record.chunks[String(index)]; + if (typeof chunk !== 'string') { + throw new Error('[rei-standard-amsg-sw] multipart missing chunk'); + } + const bytes = base64UrlToBytes(chunk); + totalBytes += bytes.byteLength; + if (totalBytes > options.maxTotalBytes) { + throw new Error('[rei-standard-amsg-sw] multipart payload exceeds maxTotalBytes'); + } + chunks.push(bytes); + } + + const json = new TextDecoder('utf-8', { fatal: false }).decode(concatBytes(chunks)); + return JSON.parse(json); +} + +async function maybeCleanupMultipart(sw, ctx) { + if (!ctx.multipart.enabled) return; + const now = Date.now(); + const last = ctx.getLastMultipartCleanupAt(); + if (last && now - last < ctx.multipart.cleanupIntervalMs) return; + ctx.setLastMultipartCleanupAt(now); + try { + await cleanupMultipartStores(sw, now); + } catch (_error) { + // Cleanup is observability/housekeeping; never block a fresh push. + } +} + +async function cleanupMultipartStores(sw, now) { + const pending = await listMultipartPending(); + for (const record of pending) { + if (record.expiresAt > now) continue; + await deleteMultipartPending(record.id); + await dispatchMultipartExpired(sw, record); + } + + const done = await listMultipartDone(); + for (const record of done) { + if (record.expiresAt <= now) { + await deleteMultipartDone(record.id); + } + } +} + +async function dispatchMultipartExpired(sw, record) { + await dispatchPushToClients(sw, REI_SW_EVENT.MULTIPART_EXPIRED, { + id: record.id, + received: record.chunks && typeof record.chunks === 'object' + ? Object.keys(record.chunks).length + : 0, + total: record.total, + originalMessageKind: record.originalMessageKind, + }); +} + +function base64UrlToBytes(input) { + const s = String(input).replace(/-/g, '+').replace(/_/g, '/'); + const pad = (4 - (s.length % 4)) % 4; + const padded = s + '='.repeat(pad); + const bin = (typeof atob === 'function') + ? atob(padded) + : Buffer.from(padded, 'base64').toString('binary'); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function concatBytes(chunks) { + let total = 0; + for (const chunk of chunks) total += chunk.byteLength; + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.byteLength; + } + return out; +} + async function enqueueAndFlush(sw, event, requestPayload) { try { const request = normalizeQueuedRequest(requestPayload); @@ -423,14 +716,128 @@ function respondToSender(event, message) { } } +function readMultipartPending(id) { + return readStoreRecord(REI_SW_MULTIPART_STORE, id); +} + +function writeMultipartPending(record) { + return putStoreRecord(REI_SW_MULTIPART_STORE, record); +} + +function deleteMultipartPending(id) { + return deleteStoreRecord(REI_SW_MULTIPART_STORE, id); +} + +function listMultipartPending() { + return listStoreRecords(REI_SW_MULTIPART_STORE); +} + +function readMultipartDone(id) { + return readStoreRecord(REI_SW_MULTIPART_DONE_STORE, id); +} + +function writeMultipartDone(record) { + return putStoreRecord(REI_SW_MULTIPART_DONE_STORE, record); +} + +function deleteMultipartDone(id) { + return deleteStoreRecord(REI_SW_MULTIPART_DONE_STORE, id); +} + +function listMultipartDone() { + return listStoreRecords(REI_SW_MULTIPART_DONE_STORE); +} + +async function readStoreRecord(storeName, id) { + if (!hasIndexedDB()) { + return cloneRecord(memoryStoreFor(storeName).get(id)); + } + + return withDatabaseStore(storeName, 'readonly', (store, resolve, reject) => { + const request = store.get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error || new Error(`Failed to read ${storeName}`)); + }); +} + +async function putStoreRecord(storeName, record) { + if (!hasIndexedDB()) { + memoryStoreFor(storeName).set(record.id, cloneRecord(record)); + return; + } + + return withDatabaseStore(storeName, 'readwrite', (store, resolve, reject) => { + const request = store.put(record); + request.onsuccess = () => resolve(undefined); + request.onerror = () => reject(request.error || new Error(`Failed to write ${storeName}`)); + }); +} + +async function deleteStoreRecord(storeName, id) { + if (!hasIndexedDB()) { + memoryStoreFor(storeName).delete(id); + return; + } + + return withDatabaseStore(storeName, 'readwrite', (store, resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(undefined); + request.onerror = () => reject(request.error || new Error(`Failed to delete ${storeName}`)); + }); +} + +async function listStoreRecords(storeName) { + if (!hasIndexedDB()) { + return Array.from(memoryStoreFor(storeName).values()).map(cloneRecord); + } + + return withDatabaseStore(storeName, 'readonly', (store, resolve, reject) => { + const request = store.getAll(); + request.onsuccess = () => resolve(Array.isArray(request.result) ? request.result : []); + request.onerror = () => reject(request.error || new Error(`Failed to list ${storeName}`)); + }); +} + +async function withDatabaseStore(storeName, mode, handler) { + const db = await openQueueDatabase(); + try { + return await new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, mode); + const store = transaction.objectStore(storeName); + transaction.onerror = () => reject(transaction.error || new Error('Database transaction failed')); + Promise.resolve(handler(store, resolve, reject)).catch(reject); + }); + } finally { + db.close(); + } +} + +function hasIndexedDB() { + return typeof indexedDB !== 'undefined' && + indexedDB && + typeof indexedDB.open === 'function'; +} + +function memoryStoreFor(storeName) { + if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone; + if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending; + throw new Error(`[rei-standard-amsg-sw] unknown memory store: ${storeName}`); +} + +function cloneRecord(record) { + if (record == null) return null; + return JSON.parse(JSON.stringify(record)); +} + function openQueueDatabase() { return new Promise((resolve, reject) => { const request = indexedDB.open(REI_SW_DB_NAME, REI_SW_DB_VERSION); request.onupgradeneeded = () => { const db = request.result; - if (db.objectStoreNames.contains(REI_SW_DB_STORE)) return; - db.createObjectStore(REI_SW_DB_STORE, { keyPath: 'id', autoIncrement: true }); + createObjectStoreIfMissing(db, REI_SW_DB_STORE, { keyPath: 'id', autoIncrement: true }); + createObjectStoreIfMissing(db, REI_SW_MULTIPART_STORE, { keyPath: 'id' }); + createObjectStoreIfMissing(db, REI_SW_MULTIPART_DONE_STORE, { keyPath: 'id' }); }; request.onsuccess = () => resolve(request.result); @@ -438,6 +845,11 @@ function openQueueDatabase() { }); } +function createObjectStoreIfMissing(db, name, options) { + if (db.objectStoreNames.contains(name)) return; + db.createObjectStore(name, options); +} + async function withQueueStore(mode, handler) { const db = await openQueueDatabase(); diff --git a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs index 5938fe4..667be9c 100644 --- a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs +++ b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs @@ -81,6 +81,34 @@ const COMMON = Object.freeze({ timestamp: '2026-05-19T00:00:00.000Z' }); +function buildMultipartPayloads(payload, { + id = `mp_test_${Math.random().toString(16).slice(2)}`, + maxChunkBytes = 80, + ttlMs = 60_000, + createdAt = Date.now(), +} = {}) { + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + const total = Math.ceil(bytes.byteLength / maxChunkBytes); + return Array.from({ length: total }, (_, index) => { + const start = index * maxChunkBytes; + const chunk = bytes.subarray(start, Math.min(start + maxChunkBytes, bytes.byteLength)); + return { + messageKind: '_multipart', + multipart: { + version: 1, + id, + index: index + 1, + total, + encoding: 'json-utf8-base64url', + originalMessageKind: typeof payload.messageKind === 'string' ? payload.messageKind : null, + createdAt, + ttlMs, + }, + chunk: Buffer.from(chunk).toString('base64url'), + }; + }); +} + test('installReiSW registers the push listener', () => { const { sw, listeners } = createSwMock(); installReiSW(sw); @@ -239,6 +267,128 @@ test('blob envelope with messageKind: "tool_request" dispatches TOOL_REQUEST_REC assert.equal(postedMessages[0].message.event, REI_SW_EVENT.TOOL_REQUEST_RECEIVED); }); +test('generic multipart content restores payload before dispatch and notification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'content', + message: 'oversized content '.repeat(20), + title: 'Multipart Rei' + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_content', maxChunkBytes: 90 }); + + for (const part of parts.slice().reverse()) { + await triggerPush(part); + } + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].title, 'Multipart Rei'); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.CONTENT_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, payload); +}); + +test('generic multipart tool_request restores silently without notification', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'tool_request', + message: 'call a large tool', + toolCalls: [{ id: 'call_0', type: 'function', function: { name: 'bulk', arguments: 'x'.repeat(500) } }] + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_tool', maxChunkBytes: 100 }); + + for (const part of parts) { + await triggerPush(part); + } + + assert.equal(notifications.length, 0); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.TOOL_REQUEST_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, payload); +}); + +test('generic multipart custom messageKind restores and dispatches UNKNOWN_RECEIVED', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'emotion_update', + mood: 'curious', + detail: 'x'.repeat(400) + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_emotion', maxChunkBytes: 90 }); + + for (const part of parts) { + await triggerPush(part); + } + + assert.equal(notifications.length, 0); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.UNKNOWN_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, payload); +}); + +test('generic multipart ignores duplicate chunks and duplicate completion', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'reasoning', + reasoningContent: 'thinking '.repeat(80) + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_dedupe', maxChunkBytes: 100 }); + + await triggerPush(parts[0]); + await triggerPush(parts[0]); + assert.equal(postedMessages.length, 0); + + for (const part of parts.slice(1)) { + await triggerPush(part); + } + assert.equal(notifications.length, 0); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.REASONING_RECEIVED); + + await triggerPush(parts[parts.length - 1]); + assert.equal(postedMessages.length, 1, 'done marker prevents duplicate business dispatch'); +}); + +test('generic multipart missing chunks do not dispatch and expire observably', async () => { + const { sw, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'reasoning', + reasoningContent: 'partial '.repeat(50) + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_expire', maxChunkBytes: 80, ttlMs: 1 }); + + await triggerPush(parts[0]); + assert.equal(postedMessages.length, 0); + + await new Promise((resolve) => setTimeout(resolve, 5)); + await triggerPush({ ...COMMON, messageKind: 'error', code: 'NOOP', message: 'tick cleanup' }); + + const expired = postedMessages.find((entry) => + entry.message.event === REI_SW_EVENT.MULTIPART_EXPIRED + ); + assert.ok(expired, 'expected multipart expired event'); + assert.deepEqual(expired.message.payload, { + id: 'mp_sw_expire', + received: 1, + total: parts.length, + originalMessageKind: 'reasoning' + }); +}); + test('clients.matchAll is called with type:"window" and includeUncontrolled:true', async () => { const { sw, triggerPush } = createSwMock(); installReiSW(sw); From c3de7ae31935fc92d9914dc5d227d3aa48608c3f Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Sun, 24 May 2026 23:01:59 +0800 Subject: [PATCH 27/33] feat(amsg-sw): add onBusinessPayload offline hook (2.1.0-next.3) --- packages/rei-standard-amsg/sw/CHANGELOG.md | 5 +++++ packages/rei-standard-amsg/sw/README.md | 10 +++++++++- packages/rei-standard-amsg/sw/package.json | 2 +- packages/rei-standard-amsg/sw/src/index.js | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index ec00bec..ac21f2b 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog — @rei-standard/amsg-sw +## 2.1.0-next.3 — 新增 `onBusinessPayload` 离线钩子 (pre-release) + +- **新增**:`installReiSW` 的 options 参数增加 `onBusinessPayload: (payload: any) => void | Promise` 钩子,支持业务端自行拦截完整的解析后 payload 并离线写库。 +- **功能集成**:在 SW 进行系统通知展示和 `postMessage` 客户端派发前,回调该拦截器。该钩子自动被融合进 `event.waitUntil` 生命周期链路,支持返回 `Promise` 以绝对保证离线写入能够在 SW 休眠前全部执行完毕。 + ## 2.1.0-next.2 — BREAKING: generic multipart reassembly (pre-release) next 阶段统一 multipart transport。SW 现在识别 `messageKind: "_multipart"` 的运输层分片,透明还原原始 payload 后再按原始 `messageKind` 走现有分发和通知策略。 diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index 792fd0f..7b05ee9 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -78,6 +78,11 @@ installReiSW(self, { maxTotalBytes: 256_000, maxChunks: 128, cleanupIntervalMs: 15 * 60_000 + }, + // (新增于 2.1.0-next.3)离线持久化等业务拦截钩子: + onBusinessPayload: async (payload) => { + // 收到完整 payload 时触发,由于内置在 event.waitUntil 中,能够确保离线写库完毕再允许 SW 休眠 + // await db.saveIncomingMessage(payload); } }); ``` @@ -125,7 +130,10 @@ import { installReiSW } from '@rei-standard/amsg-sw'; installReiSW(self, { defaultIcon: '/icon-192x192.png', defaultBadge: '/badge-72x72.png', - multipart: { enabled: true } + multipart: { enabled: true }, + onBusinessPayload: async (payload) => { + // 这里可安全地进行应用级别的离线数据库存储 + } }); // 业务侧自行实现点击跳转 diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index c758c9a..31b4217 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.2", + "version": "2.1.0-next.3", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 6455482..6a02f0e 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -111,6 +111,7 @@ export const REI_SW_MESSAGE_TYPE = Object.freeze({ * @property {number} [multipart.maxTotalBytes=256000] * @property {number} [multipart.maxChunks=128] * @property {number} [multipart.cleanupIntervalMs=900000] + * @property {(payload: any) => void | Promise} [onBusinessPayload] */ /** @@ -133,6 +134,7 @@ export function installReiSW(sw, opts = {}) { defaultBadge, defaultIcon, multipart, + onBusinessPayload: opts.onBusinessPayload, getLastMultipartCleanupAt: () => lastMultipartCleanupAt, setLastMultipartCleanupAt: (value) => { lastMultipartCleanupAt = value; }, })); @@ -174,6 +176,7 @@ async function handlePushPayload(sw, payload, ctx) { await dispatchBusinessPayload(sw, payload, { defaultIcon: ctx.defaultIcon, defaultBadge: ctx.defaultBadge, + onBusinessPayload: ctx.onBusinessPayload, }); } @@ -193,6 +196,19 @@ async function dispatchBusinessPayload(sw, payload, defaults) { } } + if (typeof defaults.onBusinessPayload === 'function') { + try { + const result = defaults.onBusinessPayload(payload); + if (result instanceof Promise) { + work.push(result.catch(error => { + console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error); + })); + } + } catch (error) { + console.error('[rei-standard-amsg-sw] onBusinessPayload error:', error); + } + } + await Promise.all(work); } From 26894f9abd51b49d0a7b4f9234a6bebe3f72f27a Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 25 May 2026 00:27:37 +0800 Subject: [PATCH 28/33] feat(amsg): push notification.show support and multipart optimization --- .../rei-standard-amsg/client/CHANGELOG.md | 4 + .../rei-standard-amsg/client/package.json | 4 +- .../rei-standard-amsg/instant/CHANGELOG.md | 4 + .../rei-standard-amsg/instant/package.json | 4 +- .../rei-standard-amsg/server/CHANGELOG.md | 4 + .../rei-standard-amsg/server/package.json | 4 +- .../rei-standard-amsg/shared/CHANGELOG.md | 6 + .../rei-standard-amsg/shared/package.json | 2 +- .../rei-standard-amsg/shared/src/index.js | 90 ++++-- packages/rei-standard-amsg/sw/CHANGELOG.md | 11 + packages/rei-standard-amsg/sw/package.json | 4 +- packages/rei-standard-amsg/sw/src/index.js | 300 ++++++++++++------ .../sw/test/dispatch.test.mjs | 131 +++++++- 13 files changed, 445 insertions(+), 123 deletions(-) diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index f0e864e..b6d321a 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-client +## 2.3.0-next.2 — Dependency bump (pre-release) + +- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至 `0.1.0-next.4`。 + ## 2.3.0-next.1 — avatarUrl 本地软清空 (pre-release) Cherry-pick stable `2.2.4` 的本地 `avatarUrl` 软清空到 next 预发布线。`scheduleMessage` / `sendInstant` / `updateMessage` 不合法的 `avatarUrl`(`data:` URI / 长度 > 2048 / 非字符串)改为 `console.warn` + 在 payload 上置 `null`(`updateMessage` 路径走 `delete` 以保留服务端原头像),请求继续发送。`Error.code === 'INVALID_AVATAR_URL_LOCAL'` 已移除;`PAYLOAD_TOO_LARGE_LOCAL`(3KB 体积上限)保留不变。详见 `2.2.4` stable 条目;与 `@rei-standard/amsg-server` 2.4.0-next.1 / `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index 0ab2b75..2f34d64 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-client", - "version": "2.3.0-next.1", + "version": "2.3.0-next.2", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0" + "@rei-standard/amsg-shared": "0.1.0-next.4" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 76ed739..c0a9a97 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.7 — Dependency bump (pre-release) + +- 依赖更新:升级 `@rei-standard/amsg-shared` 到 `0.1.0-next.4` 以获取最新的 `notification.show` 和 `multipart` 相关工具。删除了项目内的 `base64` / `concat` 工具函数,迁移使用 `amsg-shared` 导出的底层工具,提升代码可维护性。 + ## 0.8.0-next.6 — BREAKING: generic multipart transport (pre-release) next 阶段把 oversized push 的 transport 收敛成一套通用 multipart 协议。旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除;`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,都可以被 `_multipart` 包装。 diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index c098ae5..e52d2e6 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.6", + "version": "0.8.0-next.7", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", @@ -84,7 +84,7 @@ "node": ">=18" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.3" + "@rei-standard/amsg-shared": "0.1.0-next.4" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index 924bc70..c39e22a 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-server +## 2.4.0-next.2 — Dependency bump (pre-release) + +- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至 `0.1.0-next.4`。 + ## 2.4.0-next.1 — avatarUrl 软清空 (pre-release) Cherry-pick stable `2.3.3` 的 `avatarUrl` 软清空策略到 next 预发布线。把 2.3.1 引入的"严格 400"放宽为"`console.warn` + 把 `avatarUrl` 置空 + 继续":`schedule-message` 不合法的 `avatarUrl` 在 payload 上置 `null`,`update-message` 把不合法字段从 patch 里 `delete`(旧头像保持不变)。`INVALID_PARAMETERS` / `INVALID_UPDATE_DATA` 不再为 `avatarUrl` 触发,其它字段错误码不变。详见 `2.3.3` stable 条目;与 `@rei-standard/amsg-instant` 0.8.0-next.1 / `@rei-standard/amsg-client` 2.3.0-next.1 / `@rei-standard/amsg-sw` 2.1.0-next.1(SW 标题 fallback 至 `来自 {contactName}`)同步。 diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 645fff9..0665cb3 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-server", - "version": "2.4.0-next.1", + "version": "2.4.0-next.2", "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.", "repository": { "type": "git", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0", + "@rei-standard/amsg-shared": "0.1.0-next.4", "web-push": "^3.6.7", "@netlify/blobs": "^8.1.0" }, diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md index 86ab993..0e49b60 100644 --- a/packages/rei-standard-amsg/shared/CHANGELOG.md +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -1,5 +1,11 @@ # @rei-standard/amsg-shared +## 0.1.0-next.4 — NotificationDirective 与 Shared utilities (pre-release) + +### New +- **Shared Utilities**:新增并导出了底层工具函数 `base64UrlToBytes`, `toUint8`, 和 `concatBytes`,统一了底层依赖。 +- **NotificationDirective**:新增了对 `notification.show` (`"auto"` | `"always"` | `"when-hidden"` | `false`) 参数的类型定义与验证逻辑。 + ## 0.1.0-next.3 — `notification` 字段 typed support (pre-release) Coordinated with `@rei-standard/amsg-instant@0.8.0-next.3`. Install with `npm install @rei-standard/amsg-shared@next`. Wire format unchanged — additive typedef + new optional builder arg. diff --git a/packages/rei-standard-amsg/shared/package.json b/packages/rei-standard-amsg/shared/package.json index 2542b5d..9725d89 100644 --- a/packages/rei-standard-amsg/shared/package.json +++ b/packages/rei-standard-amsg/shared/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.3", + "version": "0.1.0-next.4", "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/shared/src/index.js b/packages/rei-standard-amsg/shared/src/index.js index bde8871..0cfafb7 100644 --- a/packages/rei-standard-amsg/shared/src/index.js +++ b/packages/rei-standard-amsg/shared/src/index.js @@ -95,33 +95,28 @@ export const PUSH_SOURCE = Object.freeze({ * @property {string} timestamp - ISO 8601 timestamp at producer. * @property {string} [messageSubtype] - Caller-defined business namespace. Defaults to 'chat' at producers. * @property {Object} [metadata] - Caller passthrough. Packages MUST NOT write here. + * @property {NotificationDirective} [notification] - SW notification strategy. */ // ─── Per-kind interfaces ──────────────────────────────────────────────── /** - * SW-rendering directive carried on `ContentPush` / `ToolRequestPush`. - * Mirrors the seven fields that `amsg-sw`'s `createNotificationFromPayload` - * actually consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction}`) - * — typing all seven (rather than just `title` / `body`) so callers - * don't lose IDE checking on the other five and slip back into the - * untyped-spread footgun this typedef was added to close. + * SW-rendering directive. Mirrors the fields that `amsg-sw`'s + * `createNotificationFromPayload` consumes (`notification.{title,body,icon,badge,tag,renotify,requireInteraction,data}`) + * — typing all fields so callers don't lose IDE checking and slip back into the untyped-spread footgun. * * Routing in SW (kept here so producers don't have to cross-check): - * - `messageKind: 'content'` (and legacy un-kinded payloads) → - * `notification.*` is consulted, with per-field fallback to + * - By default (`show: "auto"` or omitted), `messageKind: 'content'` (and legacy un-kinded payloads) + * will display a system notification. `reasoning` / `tool_request` / `error` will dispatch silently. + * - `show: "always"`, `"when-hidden"`, or `false` overrides this default. + * - When rendering, `notification.*` is consulted, with per-field fallback to * the top-level `title` / `avatarUrl` / `messageId` and finally * to the SW's `defaultIcon` / `defaultBadge` options. Everything * else (`tag`, `renotify`, `requireInteraction`) has no top-level - * fallback — set them under `notification` or accept the SW - * default (`messageId`-derived tag, no renotify, no requireInteraction). - * - `messageKind: 'reasoning'` / `'tool_request'` / `'error'` → - * dispatched silently to controlled clients. `notification` is - * ignored. (It's still typed on `ToolRequestPush` because the - * splitter demotes prefix chunks to `messageKind: 'content'`, at - * which point the field starts mattering.) + * fallback — set them under `notification` or accept the SW default. * * @typedef {Object} NotificationDirective + * @property {"auto" | "always" | "when-hidden" | false} [show] - Rendering strategy. Defaults to "auto" (render only if messageKind is content). * @property {string} [title] - Notification title override. * @property {string} [body] - Notification body override. * @property {string} [icon] - Icon URL override (falls back to top-level `avatarUrl` then SW `defaultIcon`). @@ -129,6 +124,7 @@ export const PUSH_SOURCE = Object.freeze({ * @property {string} [tag] - Notification grouping tag; matching tag replaces the prior notification. * @property {boolean} [renotify] - When tag matches, still vibrate/sound. Default false at SW. * @property {boolean} [requireInteraction] - Notification stays until user dismisses. Default false at SW. + * @property {Record} [data] - Custom payload data to attach to the notification. */ /** @@ -145,7 +141,6 @@ export const PUSH_SOURCE = Object.freeze({ * messageIndex?: number, * totalMessages?: number, * taskId?: string | null, - * notification?: NotificationDirective, * }} ContentPush */ @@ -201,7 +196,6 @@ export const PUSH_SOURCE = Object.freeze({ * title?: string, * contactName?: string, * message?: string, - * notification?: NotificationDirective, * }} ToolRequestPush */ @@ -354,6 +348,7 @@ export function buildReasoningPush(args) { if (typeof args.reasoningContent !== 'string' || !args.reasoningContent) { throw new Error("[amsg-shared] ReasoningPush: 'reasoningContent' must be a non-empty string"); } + validateNotificationArg('ReasoningPush', args.notification); /** @type {ReasoningPush} */ const push = { @@ -374,6 +369,7 @@ export function buildReasoningPush(args) { if (args.chunkIndex !== undefined) push.chunkIndex = args.chunkIndex; if (args.totalChunks !== undefined) push.totalChunks = args.totalChunks; if (args.metadata !== undefined) push.metadata = args.metadata; + if (args.notification !== undefined) push.notification = args.notification; return push; } @@ -435,9 +431,8 @@ export function buildToolRequestPush(args) { } /** - * Validate the optional `notification` argument on - * `buildContentPush` / `buildToolRequestPush`. Plain object required - * (`null` / arrays / primitives rejected); field-level shape is + * Validate the optional `notification` argument. + * Plain object required (`null` / arrays / primitives rejected); field-level shape is * checked best-effort — `title` / `body` / `icon` / `badge` / `tag` * must be strings when present, `renotify` / `requireInteraction` * must be booleans. Unknown keys are tolerated so the SW's @@ -452,6 +447,9 @@ function validateNotificationArg(kind, value) { throw new Error(`[amsg-shared] ${kind}: 'notification' must be a plain object`); } const n = /** @type {Record} */ (value); + if (n.show !== undefined && !['auto', 'always', 'when-hidden', false].includes(n.show)) { + throw new Error(`[amsg-shared] ${kind}: 'notification.show' must be "auto", "always", "when-hidden", or false`); + } for (const f of ['title', 'body', 'icon', 'badge', 'tag']) { if (n[f] !== undefined && typeof n[f] !== 'string') { throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a string when present`); @@ -462,6 +460,9 @@ function validateNotificationArg(kind, value) { throw new Error(`[amsg-shared] ${kind}: 'notification.${f}' must be a boolean when present`); } } + if (n.data !== undefined && (n.data === null || typeof n.data !== 'object' || Array.isArray(n.data))) { + throw new Error(`[amsg-shared] ${kind}: 'notification.data' must be a plain object when present`); + } } /** @@ -481,6 +482,7 @@ function validateNotificationArg(kind, value) { * @param {number} [args.iteration] * @param {string} [args.messageSubtype] * @param {Object} [args.metadata] + * @param {NotificationDirective} [args.notification] * @returns {ErrorPush} */ export function buildErrorPush(args) { @@ -492,6 +494,7 @@ export function buildErrorPush(args) { if (typeof args.message !== 'string') { throw new Error("[amsg-shared] ErrorPush: 'message' must be a string"); } + validateNotificationArg('ErrorPush', args.notification); /** @type {ErrorPush} */ const push = { @@ -507,6 +510,7 @@ export function buildErrorPush(args) { if (args.iteration !== undefined) push.iteration = args.iteration; if (args.messageSubtype !== undefined) push.messageSubtype = args.messageSubtype; if (args.metadata !== undefined) push.metadata = args.metadata; + if (args.notification !== undefined) push.notification = args.notification; return push; } @@ -632,3 +636,49 @@ export function chunkReasoningByUtf8Bytes(text, maxBytes) { } return chunks; } + +// ─── Shared Utilities ─────────────────────────────────────────────────── + +/** + * Coerce ArrayBuffer | Uint8Array | view → Uint8Array (no copy when possible). + */ +export function toUint8(buf) { + if (buf instanceof Uint8Array) return buf; + if (buf instanceof ArrayBuffer) return new Uint8Array(buf); + if (ArrayBuffer.isView(buf)) return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + throw new TypeError('Expected ArrayBuffer / Uint8Array'); +} + +/** + * Decode base64url (with or without padding) → Uint8Array. + * @param {string} input + * @returns {Uint8Array} + */ +export function base64UrlToBytes(input) { + const s = String(input).replace(/-/g, '+').replace(/_/g, '/'); + const pad = (4 - (s.length % 4)) % 4; + const padded = s + '='.repeat(pad); + const bin = (typeof atob === 'function') + ? atob(padded) + : Buffer.from(padded, 'base64').toString('binary'); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +/** + * Concatenate Uint8Arrays into a single Uint8Array. + * @param {...(Uint8Array | ArrayBuffer | ArrayBufferView)} chunks + * @returns {Uint8Array} + */ +export function concatBytes(...chunks) { + let total = 0; + for (const c of chunks) total += c.byteLength; + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c instanceof Uint8Array ? c : new Uint8Array(c.buffer || c), offset); + offset += c.byteLength; + } + return out; +} diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index ac21f2b..f5f4f7b 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog — @rei-standard/amsg-sw +## 2.1.0-next.4 — notification.show 及 Multipart chunk store (pre-release) + +### New +- **`notification.show`** 通知显示策略: 支持 `"auto"` | `"always"` | `"when-hidden"` | `false`。现在可以直接通过包级策略实现 "有可见窗口时静默,无可见窗口时弹通知" (`"when-hidden"`) 等应用场景。 + +### Changed +- **性能优化**:`dispatchBusinessPayload` 现在只会调用一次 `sw.clients.matchAll` 从而避免多余的 IPC 开销。 +- **IndexedDB 性能优化**:通过 `cachedDB` 保持 DB 连接,防止碎片化的 `openQueueDatabase` 导致的延迟。`REI_SW_DB_VERSION` 升级至 `3`。 +- **Multipart Chunk Store**:新增 `multipart-chunk` object store 用于独立存储分片的 payload,提升了超大 payload 还原的内存稳定性和入库速度。添加了 `expiresAt` 索引大幅加速清理超时数据的过程。 +- **去处硬编码**:移除了 `createNotificationFromPayload` 中硬编码的 “来自 {contactName}” 兜底,现在只会优雅地 fallback 到 `payload.contactName`。使用 `amsg-shared` 导出的 `MESSAGE_KIND` 枚举替代了魔法字符串。 + ## 2.1.0-next.3 — 新增 `onBusinessPayload` 离线钩子 (pre-release) - **新增**:`installReiSW` 的 options 参数增加 `onBusinessPayload: (payload: any) => void | Promise` 钩子,支持业务端自行拦截完整的解析后 payload 并离线写库。 diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index 31b4217..c6c30dc 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.3", + "version": "2.1.0-next.4", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0" + "@rei-standard/amsg-shared": "0.1.0-next.4" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 6a02f0e..9ef3b08 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -51,11 +51,15 @@ * @typedef {import('@rei-standard/amsg-shared').ErrorPush} ErrorPush */ +import { MESSAGE_KIND, base64UrlToBytes, concatBytes } from '@rei-standard/amsg-shared'; + const REI_SW_DB_NAME = 'rei-sw'; const REI_SW_DB_STORE = 'request-outbox'; const REI_SW_MULTIPART_STORE = 'multipart-pending'; const REI_SW_MULTIPART_DONE_STORE = 'multipart-done'; -const REI_SW_DB_VERSION = 2; +const REI_SW_MULTIPART_CHUNK_STORE = 'multipart-chunk'; +const REI_SW_DB_VERSION = 3; +let cachedDB = null; const REI_SW_SYNC_TAG = 'rei-sw-flush-request-outbox'; const MULTIPART_MESSAGE_KIND = '_multipart'; const MULTIPART_ENCODING = 'json-utf8-base64url'; @@ -68,6 +72,7 @@ const DEFAULT_MULTIPART_OPTIONS = Object.freeze({ }); const memoryMultipartPending = new Map(); const memoryMultipartDone = new Map(); +const memoryMultipartChunks = new Map(); /** * Wire-level message type for SW → client postMessage envelopes. @@ -182,10 +187,33 @@ async function handlePushPayload(sw, payload, ctx) { async function dispatchBusinessPayload(sw, payload, defaults) { const eventName = resolveEventName(payload); - const shouldRenderNotification = isNotificationKind(payload); + + let clientList = []; + try { + clientList = await sw.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + } catch (_matchError) { + // Ignored + } + + let shouldRenderNotification = false; + const showOpt = payload && payload.notification ? payload.notification.show : undefined; + + if (showOpt === 'always') { + shouldRenderNotification = true; + } else if (showOpt === 'when-hidden') { + const hasVisibleClient = clientList.some(client => client.visibilityState === 'visible'); + shouldRenderNotification = !hasVisibleClient; + } else if (showOpt === false) { + shouldRenderNotification = false; + } else { + shouldRenderNotification = isNotificationKind(payload); + } /** @type {Array>} */ - const work = [dispatchPushToClients(sw, eventName, payload)]; + const work = [dispatchPushToClients(sw, eventName, payload, clientList)]; if (shouldRenderNotification) { const notification = createNotificationFromPayload(payload, defaults); @@ -223,13 +251,13 @@ async function dispatchBusinessPayload(sw, payload, defaults) { function resolveEventName(payload) { const kind = payload && typeof payload === 'object' ? payload.messageKind : undefined; switch (kind) { - case 'content': + case MESSAGE_KIND.CONTENT: return REI_SW_EVENT.CONTENT_RECEIVED; - case 'reasoning': + case MESSAGE_KIND.REASONING: return REI_SW_EVENT.REASONING_RECEIVED; - case 'tool_request': + case MESSAGE_KIND.TOOL_REQUEST: return REI_SW_EVENT.TOOL_REQUEST_RECEIVED; - case 'error': + case MESSAGE_KIND.ERROR: return REI_SW_EVENT.ERROR_RECEIVED; default: return REI_SW_EVENT.UNKNOWN_RECEIVED; @@ -252,7 +280,7 @@ function isNotificationKind(payload) { if (!payload || typeof payload !== 'object') return false; const kind = payload.messageKind; if (kind === undefined || kind === null) return true; - return kind === 'content'; + return kind === MESSAGE_KIND.CONTENT; } /** @@ -267,9 +295,9 @@ function isNotificationKind(payload) { * @param {Record} payload * @returns {Promise} */ -async function dispatchPushToClients(sw, eventName, payload) { +async function dispatchPushToClients(sw, eventName, payload, preFetchedClientList = null) { try { - const clientList = await sw.clients.matchAll({ + const clientList = preFetchedClientList || await sw.clients.matchAll({ type: 'window', includeUncontrolled: true }); @@ -324,12 +352,12 @@ function createNotificationFromPayload(payload, defaults) { const title = pushNotification.title || payload.title || - (payload.contactName && `来自 ${payload.contactName}`) || + payload.contactName || 'New notification'; const body = pushNotification.body || payload.body || payload.message || ''; - const data = payload.data && typeof payload.data === 'object' - ? { ...payload.data } - : {}; + const data = pushNotification.data && typeof pushNotification.data === 'object' + ? { ...pushNotification.data } + : (payload.data && typeof payload.data === 'object' ? { ...payload.data } : {}); // Keep original payload so the app can decide how to route clicks. if (data.payload == null) data.payload = payload; @@ -422,29 +450,38 @@ async function acceptMultipartChunk(sw, payload, options) { total: normalized.total, originalMessageKind: normalized.originalMessageKind, encoding: normalized.encoding, - chunks: {}, + receivedCount: 0, receivedBytes: 0, }; if (base.total !== normalized.total || base.encoding !== normalized.encoding) { await deleteMultipartPending(normalized.id); + await deleteMultipartChunks(normalized.id, base.total); return null; } - if (base.chunks[String(normalized.index)] !== undefined) { - return null; - } + const chunkId = `${normalized.id}_${normalized.index}`; + const chunkExists = await hasMultipartChunk(chunkId); + if (chunkExists) return null; - base.chunks[String(normalized.index)] = normalized.chunk; + base.receivedCount++; base.receivedBytes = positiveIntegerOrDefault(base.receivedBytes, 0) + normalized.chunkBytes.byteLength; + if (base.receivedBytes > options.maxTotalBytes) { await deleteMultipartPending(normalized.id); + await deleteMultipartChunks(normalized.id, base.total); return null; } - const received = Object.keys(base.chunks).length; - if (received < base.total) { + await writeMultipartChunk({ + id_index: chunkId, + id: normalized.id, + index: normalized.index, + chunk: normalized.chunk + }); + + if (base.receivedCount < base.total) { await writeMultipartPending(base); return null; } @@ -452,10 +489,12 @@ async function acceptMultipartChunk(sw, payload, options) { await deleteMultipartPending(base.id); let restored; try { - restored = restoreMultipartPayload(base, options); + restored = await restoreMultipartPayload(base, options); } catch (_error) { + await deleteMultipartChunks(base.id, base.total); return null; } + await deleteMultipartChunks(base.id, base.total); // Keep the done marker longer than the pending TTL so push-service // redelivery cannot trigger a second business event after completion. const doneTtlMs = Math.max(base.ttlMs * 2, base.ttlMs + 1); @@ -478,7 +517,7 @@ function normalizeMultipartChunk(payload, options) { let chunkBytes; try { chunkBytes = base64UrlToBytes(payload.chunk); - } catch (_error) { + } catch (_error) { console.error("RESTORE ERROR", _error); return null; } @@ -506,16 +545,16 @@ function normalizeMultipartChunk(payload, options) { }; } -function restoreMultipartPayload(record, options) { +async function restoreMultipartPayload(record, options) { /** @type {Uint8Array[]} */ const chunks = []; let totalBytes = 0; for (let index = 1; index <= record.total; index++) { - const chunk = record.chunks[String(index)]; - if (typeof chunk !== 'string') { + const chunkRecord = await readMultipartChunk(record.id, index); + if (!chunkRecord || typeof chunkRecord.chunk !== 'string') { throw new Error('[rei-standard-amsg-sw] multipart missing chunk'); } - const bytes = base64UrlToBytes(chunk); + const bytes = base64UrlToBytes(chunkRecord.chunk); totalBytes += bytes.byteLength; if (totalBytes > options.maxTotalBytes) { throw new Error('[rei-standard-amsg-sw] multipart payload exceeds maxTotalBytes'); @@ -523,7 +562,7 @@ function restoreMultipartPayload(record, options) { chunks.push(bytes); } - const json = new TextDecoder('utf-8', { fatal: false }).decode(concatBytes(chunks)); + const json = new TextDecoder('utf-8', { fatal: false }).decode(concatBytes(...chunks)); return JSON.parse(json); } @@ -535,61 +574,73 @@ async function maybeCleanupMultipart(sw, ctx) { ctx.setLastMultipartCleanupAt(now); try { await cleanupMultipartStores(sw, now); - } catch (_error) { + } catch (_error) { console.error("RESTORE ERROR", _error); // Cleanup is observability/housekeeping; never block a fresh push. } } async function cleanupMultipartStores(sw, now) { - const pending = await listMultipartPending(); - for (const record of pending) { - if (record.expiresAt > now) continue; - await deleteMultipartPending(record.id); + if (!hasIndexedDB()) { + for (const [id, record] of memoryMultipartPending.entries()) { + if (record.expiresAt <= now) { + memoryMultipartPending.delete(id); + await deleteMultipartChunks(id, record.total); + await dispatchMultipartExpired(sw, record); + } + } + for (const [id, record] of memoryMultipartDone.entries()) { + if (record.expiresAt <= now) { + memoryMultipartDone.delete(id); + } + } + return; + } + + const pendingExpired = await withDatabaseStore(REI_SW_MULTIPART_STORE, 'readonly', (store, resolve, reject) => { + const index = store.index('expiresAt'); + const range = IDBKeyRange.upperBound(now); + const req = index.getAll(range); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + + for (const record of pendingExpired) { + await deleteStoreRecord(REI_SW_MULTIPART_STORE, record.id); + await deleteMultipartChunks(record.id, record.total); await dispatchMultipartExpired(sw, record); } - const done = await listMultipartDone(); - for (const record of done) { - if (record.expiresAt <= now) { - await deleteMultipartDone(record.id); + const doneExpiredKeys = await withDatabaseStore(REI_SW_MULTIPART_DONE_STORE, 'readonly', (store, resolve, reject) => { + const index = store.index('expiresAt'); + const range = IDBKeyRange.upperBound(now); + if (index.getAllKeys) { + const req = index.getAllKeys(range); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + } else { + const req = index.getAll(range); + req.onsuccess = () => resolve((req.result || []).map(r => r.id)); + req.onerror = () => reject(req.error); } + }); + + for (const id of doneExpiredKeys) { + await deleteStoreRecord(REI_SW_MULTIPART_DONE_STORE, id); } } async function dispatchMultipartExpired(sw, record) { await dispatchPushToClients(sw, REI_SW_EVENT.MULTIPART_EXPIRED, { id: record.id, - received: record.chunks && typeof record.chunks === 'object' - ? Object.keys(record.chunks).length + received: typeof record.receivedCount === 'number' + ? record.receivedCount : 0, total: record.total, originalMessageKind: record.originalMessageKind, }); } -function base64UrlToBytes(input) { - const s = String(input).replace(/-/g, '+').replace(/_/g, '/'); - const pad = (4 - (s.length % 4)) % 4; - const padded = s + '='.repeat(pad); - const bin = (typeof atob === 'function') - ? atob(padded) - : Buffer.from(padded, 'base64').toString('binary'); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} -function concatBytes(chunks) { - let total = 0; - for (const chunk of chunks) total += chunk.byteLength; - const out = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.byteLength; - } - return out; -} async function enqueueAndFlush(sw, event, requestPayload) { try { @@ -669,7 +720,7 @@ function normalizeRequestBody(bodyInput) { try { return JSON.stringify(bodyInput); - } catch (_error) { + } catch (_error) { console.error("RESTORE ERROR", _error); throw new Error('[rei-standard-amsg-sw] request body is not serializable'); } } @@ -703,7 +754,7 @@ async function trySendQueuedRequest(queuedRequest) { } return false; - } catch (_error) { + } catch (_error) { console.error("RESTORE ERROR", _error); return false; } } @@ -714,7 +765,7 @@ async function registerFlushSync(sw) { try { await syncManager.register(REI_SW_SYNC_TAG); - } catch (_error) { + } catch (_error) { console.error("RESTORE ERROR", _error); // Ignore unsupported/denied sync registration and rely on manual flush. } } @@ -764,6 +815,59 @@ function listMultipartDone() { return listStoreRecords(REI_SW_MULTIPART_DONE_STORE); } +async function hasMultipartChunk(id_index) { + if (!hasIndexedDB()) return memoryMultipartChunks.has(id_index); + return withDatabaseStore(REI_SW_MULTIPART_CHUNK_STORE, 'readonly', (store, resolve, reject) => { + const request = store.count(id_index); + request.onsuccess = () => resolve(request.result > 0); + request.onerror = () => reject(request.error); + }); +} + +function writeMultipartChunk(record) { + if (!hasIndexedDB()) { + memoryMultipartChunks.set(record.id_index, cloneRecord(record)); + return Promise.resolve(); + } + return putStoreRecord(REI_SW_MULTIPART_CHUNK_STORE, record); +} + +function readMultipartChunk(id, index) { + const id_index = `${id}_${index}`; + if (!hasIndexedDB()) { + return Promise.resolve(cloneRecord(memoryMultipartChunks.get(id_index) || null)); + } + return readStoreRecord(REI_SW_MULTIPART_CHUNK_STORE, id_index); +} + +async function deleteMultipartChunks(id, total) { + if (!hasIndexedDB()) { + for (let index = 1; index <= total; index++) { + memoryMultipartChunks.delete(`${id}_${index}`); + } + return; + } + return withDatabaseStore(REI_SW_MULTIPART_CHUNK_STORE, 'readwrite', (store, resolve, reject) => { + let pending = total; + let failed = false; + for (let index = 1; index <= total; index++) { + const request = store.delete(`${id}_${index}`); + request.onsuccess = () => { + if (failed) return; + pending--; + if (pending === 0) resolve(undefined); + }; + request.onerror = () => { + if (!failed) { + failed = true; + reject(request.error); + } + }; + } + if (total === 0) resolve(undefined); + }); +} + async function readStoreRecord(storeName, id) { if (!hasIndexedDB()) { return cloneRecord(memoryStoreFor(storeName).get(id)); @@ -816,16 +920,12 @@ async function listStoreRecords(storeName) { async function withDatabaseStore(storeName, mode, handler) { const db = await openQueueDatabase(); - try { - return await new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, mode); - const store = transaction.objectStore(storeName); - transaction.onerror = () => reject(transaction.error || new Error('Database transaction failed')); - Promise.resolve(handler(store, resolve, reject)).catch(reject); - }); - } finally { - db.close(); - } + return new Promise((resolve, reject) => { + const transaction = db.transaction(storeName, mode); + const store = transaction.objectStore(storeName); + transaction.onerror = () => reject(transaction.error || new Error('Database transaction failed')); + Promise.resolve(handler(store, resolve, reject)).catch(reject); + }); } function hasIndexedDB() { @@ -837,6 +937,7 @@ function hasIndexedDB() { function memoryStoreFor(storeName) { if (storeName === REI_SW_MULTIPART_DONE_STORE) return memoryMultipartDone; if (storeName === REI_SW_MULTIPART_STORE) return memoryMultipartPending; + if (storeName === REI_SW_MULTIPART_CHUNK_STORE) return memoryMultipartChunks; throw new Error(`[rei-standard-amsg-sw] unknown memory store: ${storeName}`); } @@ -846,42 +947,55 @@ function cloneRecord(record) { } function openQueueDatabase() { + if (cachedDB) return Promise.resolve(cachedDB); + return new Promise((resolve, reject) => { const request = indexedDB.open(REI_SW_DB_NAME, REI_SW_DB_VERSION); request.onupgradeneeded = () => { const db = request.result; - createObjectStoreIfMissing(db, REI_SW_DB_STORE, { keyPath: 'id', autoIncrement: true }); - createObjectStoreIfMissing(db, REI_SW_MULTIPART_STORE, { keyPath: 'id' }); - createObjectStoreIfMissing(db, REI_SW_MULTIPART_DONE_STORE, { keyPath: 'id' }); + const tx = request.transaction; + createObjectStoreIfMissing(db, tx, REI_SW_DB_STORE, { keyPath: 'id', autoIncrement: true }); + const mpStore = createObjectStoreIfMissing(db, tx, REI_SW_MULTIPART_STORE, { keyPath: 'id' }); + const mpDoneStore = createObjectStoreIfMissing(db, tx, REI_SW_MULTIPART_DONE_STORE, { keyPath: 'id' }); + createObjectStoreIfMissing(db, tx, REI_SW_MULTIPART_CHUNK_STORE, { keyPath: 'id_index' }); + + if (mpStore && !mpStore.indexNames.contains('expiresAt')) { + mpStore.createIndex('expiresAt', 'expiresAt', { unique: false }); + } + if (mpDoneStore && !mpDoneStore.indexNames.contains('expiresAt')) { + mpDoneStore.createIndex('expiresAt', 'expiresAt', { unique: false }); + } }; - request.onsuccess = () => resolve(request.result); + request.onsuccess = () => { + cachedDB = request.result; + cachedDB.onversionchange = () => { + cachedDB.close(); + cachedDB = null; + }; + resolve(cachedDB); + }; request.onerror = () => reject(request.error || new Error('Failed to open queue database')); }); } -function createObjectStoreIfMissing(db, name, options) { - if (db.objectStoreNames.contains(name)) return; - db.createObjectStore(name, options); +function createObjectStoreIfMissing(db, tx, name, options) { + if (db.objectStoreNames.contains(name)) return tx.objectStore(name); + return db.createObjectStore(name, options); } async function withQueueStore(mode, handler) { const db = await openQueueDatabase(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(REI_SW_DB_STORE, mode); + const store = transaction.objectStore(REI_SW_DB_STORE); - try { - return await new Promise((resolve, reject) => { - const transaction = db.transaction(REI_SW_DB_STORE, mode); - const store = transaction.objectStore(REI_SW_DB_STORE); - - transaction.oncomplete = () => resolve(undefined); - transaction.onerror = () => reject(transaction.error || new Error('Queue transaction failed')); + transaction.oncomplete = () => resolve(undefined); + transaction.onerror = () => reject(transaction.error || new Error('Queue transaction failed')); - Promise.resolve(handler(store, resolve, reject)).catch(reject); - }); - } finally { - db.close(); - } + Promise.resolve(handler(store, resolve, reject)).catch(reject); + }); } async function addQueuedRequest(request) { diff --git a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs index 667be9c..1ee8baf 100644 --- a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs +++ b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs @@ -17,7 +17,7 @@ import { * `event.waitUntil` chain so tests can assert on side effects * synchronously after the await. */ -function createSwMock({ clientCount = 1 } = {}) { +function createSwMock({ clientCount = 1, visibleCount = 0 } = {}) { /** @type {Map} */ const listeners = new Map(); /** @type {Array<{ title: string, options: Record }>} */ @@ -27,6 +27,7 @@ function createSwMock({ clientCount = 1 } = {}) { const clients = Array.from({ length: clientCount }, (_, index) => ({ id: `client-${index}`, + visibilityState: index < visibleCount ? 'visible' : 'hidden', postMessage(message) { postedMessages.push({ client: index, message }); } @@ -442,3 +443,131 @@ test('one client throwing inside postMessage does not block delivery to the othe // Notification rendering ran independently of the broken client. assert.equal(notificationCount, 1); }); + +test('notification.show: "auto" or undefined behavior', async () => { + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw); + + // Content -> show + await triggerPush({ ...COMMON, messageKind: 'content', message: 'Hello' }); + assert.equal(notifications.length, 1); + + // Tool request -> no show + await triggerPush({ ...COMMON, messageKind: 'tool_request', toolCalls: [{}] }); + assert.equal(notifications.length, 1, 'Still 1 because tool_request does not show'); +}); + +test('notification.show: "always" forces notification', async () => { + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'reasoning', + reasoningContent: 'thinking', + notification: { show: 'always', title: 'Always Show' } + }); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].title, 'Always Show'); +}); + +test('notification.show: "when-hidden" shows when no visible clients', async () => { + const { sw, notifications, triggerPush } = createSwMock({ clientCount: 1, visibleCount: 0 }); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'tool_request', + toolCalls: [{}], + notification: { show: 'when-hidden', title: 'When Hidden' } + }); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].title, 'When Hidden'); +}); + +test('notification.show: "when-hidden" DOES NOT show when there is a visible client', async () => { + const { sw, notifications, triggerPush } = createSwMock({ clientCount: 2, visibleCount: 1 }); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'tool_request', + toolCalls: [{}], + notification: { show: 'when-hidden' } + }); + + assert.equal(notifications.length, 0); +}); + +test('notification.show: false prevents content notification', async () => { + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'content', + message: 'Silent message', + notification: { show: false } + }); + + assert.equal(notifications.length, 0); +}); + +test('notification.data is passed through to notification options', async () => { + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'content', + message: 'Hello', + notification: { show: 'always', data: { customField: 'value' } } + }); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].options.data.customField, 'value'); +}); + +test('multipart fully received payload with notification.show: "when-hidden" checks visible client', async () => { + // Test 1: with visible client -> no notification + { + const { sw, notifications, triggerPush } = createSwMock({ clientCount: 1, visibleCount: 1 }); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'tool_request', + toolCalls: [{}], + notification: { show: 'when-hidden' } + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_wh_visible', maxChunkBytes: 80 }); + + for (const part of parts) { + await triggerPush(part); + } + assert.equal(notifications.length, 0); + } + + // Test 2: without visible client -> notification + { + const { sw, notifications, triggerPush } = createSwMock({ clientCount: 1, visibleCount: 0 }); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'tool_request', + toolCalls: [{}], + notification: { show: 'when-hidden', title: 'Multipart Hidden' } + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_wh_hidden', maxChunkBytes: 80 }); + + for (const part of parts) { + await triggerPush(part); + } + assert.equal(notifications.length, 1); + assert.equal(notifications[0].title, 'Multipart Hidden'); + } +}); + From d678fd2a3e1ef1bd95e6dee6b8d8666dac4a0070 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 25 May 2026 00:30:30 +0800 Subject: [PATCH 29/33] chore(release): lockfile and missing files for next.4 --- bump.mjs | 18 + ...26-05-21-amsg-instant-pushpayloads-only.md | 1604 +++++++++++++++++ package-lock.json | 45 +- .../rei-standard-amsg/instant/pnpm-lock.yaml | 920 ++++++++++ .../instant/pnpm-workspace.yaml | 2 + .../rei-standard-amsg/instant/src/utils.js | 35 +- packages/rei-standard-amsg/sw/README.md | 51 +- 7 files changed, 2606 insertions(+), 69 deletions(-) create mode 100644 bump.mjs create mode 100644 docs/superpowers/plans/2026-05-21-amsg-instant-pushpayloads-only.md create mode 100644 packages/rei-standard-amsg/instant/pnpm-lock.yaml create mode 100644 packages/rei-standard-amsg/instant/pnpm-workspace.yaml diff --git a/bump.mjs b/bump.mjs new file mode 100644 index 0000000..a8458d5 --- /dev/null +++ b/bump.mjs @@ -0,0 +1,18 @@ +import fs from 'fs'; +import path from 'path'; + +function updatePkg(pkgPath, version, sharedDep) { + const file = path.resolve(pkgPath); + const json = JSON.parse(fs.readFileSync(file, 'utf8')); + json.version = version; + if (json.dependencies && json.dependencies['@rei-standard/amsg-shared']) { + json.dependencies['@rei-standard/amsg-shared'] = sharedDep; + } + fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\n'); +} + +updatePkg('packages/rei-standard-amsg/shared/package.json', '0.1.0-next.4', null); +updatePkg('packages/rei-standard-amsg/sw/package.json', '2.1.0-next.4', '0.1.0-next.4'); +updatePkg('packages/rei-standard-amsg/instant/package.json', '0.8.0-next.7', '0.1.0-next.4'); +updatePkg('packages/rei-standard-amsg/client/package.json', '2.3.0-next.2', '0.1.0-next.4'); +updatePkg('packages/rei-standard-amsg/server/package.json', '2.4.0-next.2', '0.1.0-next.4'); diff --git a/docs/superpowers/plans/2026-05-21-amsg-instant-pushpayloads-only.md b/docs/superpowers/plans/2026-05-21-amsg-instant-pushpayloads-only.md new file mode 100644 index 0000000..f138647 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-amsg-instant-pushpayloads-only.md @@ -0,0 +1,1604 @@ +# amsg-instant 0.8.0-next.4 — `pushPayloads`-only hook decision API + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `decision.pushPayload` (singular) + lib-side `splitHookPushPayload` auto-split with `decision.pushPayloads: PushPayload[]` (plural) where the hook returns the exact N pushes it wants the lib to send, and the lib does zero splitting. + +**Architecture:** The lib drops every split helper (`splitHookPushPayload`, `splitMessageIntoSentences`, `splitOnceByRegex`, `pickSplitConfig`, `validateSplitPattern`, `validatePerKindSplitPatterns`, `DEFAULT_SPLIT_REGEX`, `SPLIT_PATTERN_MAX_*`). `runAgenticLoop` calls a new `sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sleep)` that runs `for (const push of pushPayloads) await sendPushWithMaybeBlob(...)` with 1500ms gaps. Reasoning auto-emit shifts to a simpler one-shot path (no Layer-1 split; Layer-2 byte chunking via `chunkReasoningByUtf8Bytes` stays). Validation rejects the legacy fields with `HookError` so pre-release callers see the migration line immediately. The legacy v0.6 path is the *only* place `splitMessageIntoSentences` survives, because that path turns raw LLM text into N pushes itself (no hook involved); that helper stays inlined inside `runLegacyInstant` and is no longer exported. + +**Tech Stack:** Node.js (≥18) ESM, `node --test` runner, `tsup` bundler, `@rei-standard/amsg-shared` v0.1.0-next.3 push builders. + +--- + +## File map + +**Modify:** +- `packages/rei-standard-amsg/instant/src/message-processor.js` — tear out split helpers; rewrite `runAgenticLoop`'s finish/tool-request branch around `pushPayloads`; rewrite reasoning auto-emit to drop Layer-1 split; rewrite LOOP_EXCEEDED diagnostic to single-shot. +- `packages/rei-standard-amsg/instant/src/validation.js` — drop `validateSplitPattern` + `validatePerKindSplitPatterns`; reject request-body `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` with the migration message. +- `packages/rei-standard-amsg/instant/src/index.js` — drop the `splitMessageIntoSentences` re-export; refresh `InstantHandlerOptions` JSDoc; refresh `onLLMOutput` JSDoc. +- `packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs` — migrate existing tests from `pushPayload` to `pushPayloads: [push]`. +- `packages/rei-standard-amsg/instant/test/handler.test.mjs` — drop the `splitPattern` validation/`splitMessageIntoSentences` blocks (move what's salvageable into the legacy path coverage). +- `packages/rei-standard-amsg/instant/test/e2e.test.mjs` — migrate hook returns to `pushPayloads`. +- `packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs` — migrate hook returns to `pushPayloads`; drop any expectation that the reasoning path runs through `splitHookPushPayload`. +- `packages/rei-standard-amsg/instant/README.md` — rewrite the Hook section, delete the splitPattern subsections. +- `packages/rei-standard-amsg/instant/CHANGELOG.md` — prepend the `0.8.0-next.4 — BREAKING` entry. +- `packages/rei-standard-amsg/instant/package.json` — bump version to `0.8.0-next.4`. +- `packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js` — convert `pushPayload` → `pushPayloads: [push]`. + +**Delete:** +- `packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs` — entire suite is meaningless once the lib stops splitting. + +**Create:** +- `packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs` — the 13-case matrix from spec §测试要求. +- `packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md` — long-form migration guide (linked from the CHANGELOG entry). + +--- + +## Background facts an executor needs + +1. **Worktree location:** All file paths in this plan are relative to `/Users/tntobsidian/Documents/GitHub/ReiStandard/.worktrees/push-schema/`. The branch is `feature/push-schema-unification-0.8`. +2. **Current version on this branch:** `0.8.0-next.3` (see `packages/rei-standard-amsg/instant/package.json:3`). Target version: `0.8.0-next.4`. +3. **Legacy path is unaffected.** `runLegacyInstant` is the no-hook v0.6 compat path. It still calls `splitMessageIntoSentences` on raw LLM text — that is *not* the hook path's `splitHookPushPayload`. Keep `splitMessageIntoSentences` inlined inside `message-processor.js` (it's still needed for `runLegacyInstant`) but do NOT export it from `src/index.js` anymore (the only public consumer was hook authors who now have no use for it). +4. **HookError signature:** `new HookError(message, { cause? })`. The handler maps `instanceof HookError` to HTTP 500 with `error.code = 'HOOK_THREW'`. This is the right error class for any spec-§校验规则 violation. +5. **`sendPushWithMaybeBlob` already does per-push blob fallback.** Do not duplicate that logic — just call it in a loop. +6. **`SLEEP_BETWEEN_MESSAGES_MS = 1500` constant** lives at the top of `message-processor.js`. Reuse it; do not introduce a new spacing constant. +7. **`extractAssistantMessage` and `buildSessionContext`** are imported from `./session-context.js` — keep them. +8. **`readReasoningContent`** is exported from `message-processor.js` — keep it. +9. **Builders re-exported from `@rei-standard/amsg-shared`** (`buildContentPush`, `buildReasoningPush`, `buildErrorPush`, `chunkReasoningByUtf8Bytes`) are imported on line 18 of `message-processor.js`. The new reasoning path needs only `buildReasoningPush` + `chunkReasoningByUtf8Bytes`. +10. **Test runner:** `node --test test/*.test.mjs` from `packages/rei-standard-amsg/instant/`. No mocha/jest — pure node:test + node:assert/strict. Tests use `helpers.mjs` for VAPID/subscription setup + `createFetchRouter` for fetch interception + `decryptCapturedPushBody` for round-trip verification. + +--- + +## Task 1: Add the validation fences (split-pattern fields + pushPayload-singular) + +This goes first because it lets later tasks assume the hot-path code never sees the legacy fields. Pure rejection logic — no behaviour migration yet. + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/src/validation.js` (drop `validateSplitPattern` + `validatePerKindSplitPatterns`, add rejection in both `validateInstantPayload` and `validateContinuePayload`) +- Modify: `packages/rei-standard-amsg/instant/src/index.js` (drop the `splitMessageIntoSentences` re-export at line ~614) + +- [ ] **Step 1: Write a failing test for request-level `splitPattern` rejection** + +Append to `packages/rei-standard-amsg/instant/test/handler.test.mjs` (above the existing `validateInstantPayload` describe block is fine): + +```javascript +describe('next.4 — split-pattern fields removed', () => { + it('rejects request body splitPattern with INVALID_PAYLOAD_FORMAT', () => { + const r = validateInstantPayload(makeValidPayload({ splitPattern: '([。!?!?]+)' })); + assert.equal(r.valid, false); + assert.equal(r.errorCode, 'INVALID_PAYLOAD_FORMAT'); + assert.match(r.errorMessage, /splitPattern is removed in next\.4/); + }); + it('rejects request body reasoningSplitPattern', () => { + const r = validateInstantPayload(makeValidPayload({ reasoningSplitPattern: '([。!?!?]+)' })); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /reasoningSplitPattern is removed in next\.4/); + }); + it('rejects request body errorSplitPattern', () => { + const r = validateInstantPayload(makeValidPayload({ errorSplitPattern: '([。!?!?]+)' })); + assert.equal(r.valid, false); + assert.match(r.errorMessage, /errorSplitPattern is removed in next\.4/); + }); +}); +``` + +- [ ] **Step 2: Run the test, expect failures** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/handler.test.mjs` +Expected: 3 new failures complaining the payload was accepted. + +- [ ] **Step 3: Drop `validateSplitPattern` + `validatePerKindSplitPatterns` and replace with rejection** + +In `src/validation.js`: + +1. Delete the const block (`SPLIT_PATTERN_MAX_LENGTH` / `SPLIT_PATTERN_MAX_ITEMS`) at lines 50–51. +2. Delete `export function validateSplitPattern(value)` at lines 53–87. +3. Delete `function validatePerKindSplitPatterns(payload)` at lines 468–486. +4. Replace the two call sites (`const splitErr = validatePerKindSplitPatterns(payload); if (splitErr) return splitErr;`) inside `validateInstantPayload` (line 316) and `validateContinuePayload` (line 454) with this rejection block: + +```javascript +const removedField = ['splitPattern', 'reasoningSplitPattern', 'errorSplitPattern'] + .find((field) => payload[field] !== undefined); +if (removedField) { + return { + valid: false, + errorCode: 'INVALID_PAYLOAD_FORMAT', + errorMessage: `${removedField} is removed in next.4; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, + details: { invalidFields: [removedField] }, + }; +} +``` + +- [ ] **Step 4: Run the test again, expect pass** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/handler.test.mjs` +Expected: the 3 new cases pass. Existing tests that USE `splitPattern` will now fail (handler.test.mjs has a whole `splitPattern (0.6.0)` block from line ~172). That's expected — we'll fix them in Task 5 / by deletion. Note them but do not touch them this task. + +- [ ] **Step 5: Drop the `splitMessageIntoSentences` export from `src/index.js`** + +In `packages/rei-standard-amsg/instant/src/index.js`, edit the re-export block (lines 613–619): + +```javascript +// BEFORE +export { + splitMessageIntoSentences, + processInstantMessage, + normalizeAiApiUrl, + sendPushWithMaybeBlob, + readReasoningContent, +} from './message-processor.js'; + +// AFTER +export { + processInstantMessage, + normalizeAiApiUrl, + sendPushWithMaybeBlob, + readReasoningContent, +} from './message-processor.js'; +``` + +Don't delete the function from `message-processor.js` yet — `runLegacyInstant` still calls it internally. Just stop exposing it. (Step 5 only changes one line; it's batched with the other rejection work because it's the same conceptual change: the public split-related API is gone.) + +- [ ] **Step 6: Run the full instant suite — expect many failures, ALL in tests that explicitly touch splitPattern** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/*.test.mjs 2>&1 | head -80` +Expected: failures concentrated in `test/split-pattern-hook.test.mjs` (entire file), `test/handler.test.mjs` (the splitPattern block + `describe('splitMessageIntoSentences', ...)`), and any reasoning/handler test using `reasoningSplitPattern`. The agentic loop / pushPayload / blob tests should still pass. + +If unrelated tests fail, stop and investigate — the validation rejection should not have broken anything else. + +- [ ] **Step 7: Commit** + +```bash +git add packages/rei-standard-amsg/instant/src/validation.js packages/rei-standard-amsg/instant/src/index.js packages/rei-standard-amsg/instant/test/handler.test.mjs +git commit -m "$(cat <<'EOF' +feat(amsg-instant)!: 0.8.0-next.4 — reject removed split-pattern fields + +Request body fields `splitPattern` / `reasoningSplitPattern` / +`errorSplitPattern` are now rejected with INVALID_PAYLOAD_FORMAT and a +migration hint pointing at `decision.pushPayloads`. Removes +`validateSplitPattern` / `validatePerKindSplitPatterns` from validation +and stops re-exporting `splitMessageIntoSentences` (legacy path still +uses it internally; hook authors don't get it back). + +Breaking on purpose — next.4 is pre-release; we're consolidating two +overlapping mechanisms (lib-side splitPattern auto-split + hook-side +pushPayload) into one (pushPayloads array) before 1.0. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Add the validation fences for `pushPayload` (singular) + per-push `splitPattern` + +Same pattern, but inside `assertValidDecision` in `message-processor.js`, since the hook decision is validated at runtime, not at request parse time. + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/src/message-processor.js` (rewrite `assertValidDecision`) +- Modify: `packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs` (write failing tests for the new contract) + +- [ ] **Step 1: Write failing tests** + +Append to `test/agentic-loop.test.mjs` (or any agentic-loop test file — pick `agentic-loop.test.mjs` so they sit next to existing decision tests): + +```javascript +describe('next.4 — decision contract: pushPayloads', () => { + async function dispatchHookReturn(hookReturn) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('llm answer'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => hookReturn, + }); + const res = await handler(makeRequest('http://h/instant', basePayload())); + return { res, body: await res.json(), router }; + } + + it('rejects singular pushPayload field with HookError + migration message', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'hi' }, + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /pushPayload \(singular\) is removed in next\.4, use pushPayloads: \[yourPayload\]/); + }); + + it('rejects when BOTH pushPayload and pushPayloads are set', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'a' }, + pushPayloads: [{ messageKind: 'content', message: 'b' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /pushPayload \(singular\) is removed in next\.4, use pushPayloads/); + }); + + it('rejects pushPayloads: [] (empty array)', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayloads: [], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /use decision: skip-push to skip notification entirely/); + }); + + it('rejects a push that carries splitPattern', async () => { + const { res, body } = await dispatchHookReturn({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'hi', splitPattern: '([。!?!?]+)' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /splitPattern is removed in next\.4/); + }); +}); +``` + +- [ ] **Step 2: Run the test, expect failures** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/agentic-loop.test.mjs 2>&1 | tail -30` +Expected: 4 failures — calls succeed instead of throwing HookError. + +- [ ] **Step 3: Rewrite `assertValidDecision` in `src/message-processor.js`** + +Replace the existing function (lines 1035–1050) with: + +```javascript +function assertValidDecision(decision) { + if (!decision || typeof decision !== 'object') { + throw new TypeError(`onLLMOutput returned invalid decision: ${stringifyForError(decision)}`); + } + const tag = /** @type {{ decision?: unknown }} */ (decision).decision; + if (typeof tag !== 'string' || !VALID_DECISIONS.has(tag)) { + throw new TypeError(`onLLMOutput returned invalid decision tag: ${stringifyForError(tag)}`); + } + + const hasSingular = Object.prototype.hasOwnProperty.call(decision, 'pushPayload'); + const hasPlural = Object.prototype.hasOwnProperty.call(decision, 'pushPayloads'); + + if (hasSingular) { + throw new TypeError( + hasPlural + ? 'pushPayload (singular) is removed in next.4, use pushPayloads' + : 'pushPayload (singular) is removed in next.4, use pushPayloads: [yourPayload]' + ); + } + + if (tag === 'continue') { + if (!Array.isArray(/** @type {{ nextHistory?: unknown }} */ (decision).nextHistory)) { + throw new TypeError('decision:"continue" requires a nextHistory array'); + } + return; + } + + if (tag === 'skip-push') { + return; + } + + // 'finish' / 'tool-request' — both need pushPayloads array + if (!hasPlural || !Array.isArray(/** @type {{ pushPayloads?: unknown }} */ (decision).pushPayloads)) { + throw new TypeError(`decision:"${tag}" requires a pushPayloads array`); + } + const pushes = /** @type {Array} */ (decision.pushPayloads); + if (pushes.length === 0) { + throw new TypeError('pushPayloads: [] — use decision: skip-push to skip notification entirely'); + } + for (let i = 0; i < pushes.length; i++) { + const p = pushes[i]; + if (!p || typeof p !== 'object' || Array.isArray(p)) { + throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyForError(p)}`); + } + if (Object.prototype.hasOwnProperty.call(p, 'splitPattern')) { + throw new TypeError(`pushPayloads[${i}].splitPattern is removed in next.4; caller is responsible for splitting`); + } + } +} +``` + +Note: `assertValidDecision` throws `TypeError`; `runAgenticLoop`'s catch block already maps any throw from this function to `new HookError(...)` (see lines 938–960 in current code). So the error class wrapping is handled — we just throw with the right message. + +- [ ] **Step 4: Run the failing tests, expect pass** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/agentic-loop.test.mjs 2>&1 | tail -30` +Expected: the 4 new cases pass. The other agentic-loop tests still fail because they pass `pushPayload` (singular) — Task 3 fixes those. + +- [ ] **Step 5: Commit** + +```bash +git add packages/rei-standard-amsg/instant/src/message-processor.js packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +git commit -m "$(cat <<'EOF' +feat(amsg-instant)!: 0.8.0-next.4 — reject decision.pushPayload + pushPayloads:[] + +assertValidDecision now requires `pushPayloads: [...]` on `finish` / +`tool-request` decisions. The singular `pushPayload` field is rejected +with a migration line. Empty `pushPayloads: []` is rejected and points +the caller at `decision: 'skip-push'`. Per-push `splitPattern` is also +rejected. + +These cases all route through the existing HOOK_THREW pipeline. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Rewrite finish/tool-request delivery to consume `pushPayloads` + +Now that the contract is locked, swap the lib's delivery code to read the array. Strip `splitHookPushPayload`, `pickSplitConfig`, `sendChunkedPush` — they no longer have a reason to exist. + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/src/message-processor.js` (replace the finish/tool-request branch in `runAgenticLoop`) + +- [ ] **Step 1: Write the failing happy-path test** + +Append to `test/agentic-loop.test.mjs`: + +```javascript +describe('next.4 — pushPayloads happy paths', () => { + it('sends N pushes from a 3-element pushPayloads array with messageIndex/totalMessages auto-fill', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + }); + const sleeps = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + autoEmitReasoning: false, + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'first' }, + { messageKind: 'content', message: 'second' }, + { messageKind: 'content', message: 'third' }, + ], + }), + }); + // Drive through processInstantMessage directly so we can inject sleep + // and avoid the 3×1500ms wall-clock wait. + const result = await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'first' }, + { messageKind: 'content', message: 'second' }, + { messageKind: 'content', message: 'third' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + assert.equal(result.status, 'finished'); + assert.equal(router.pushCalls.length, 3); + const decoded = []; + for (const c of router.pushCalls) decoded.push(JSON.parse(await decryptCapturedPushBody(c.body, subKit))); + assert.deepEqual(decoded.map(p => p.message), ['first', 'second', 'third']); + assert.deepEqual(decoded.map(p => p.messageIndex), [1, 2, 3]); + assert.deepEqual(decoded.map(p => p.totalMessages), [3, 3, 3]); + // sleeps: 1500 between push 1↔2 and 2↔3 + assert.deepEqual(sleeps, [1500, 1500]); + }); + + it('preserves hook-set messageId, overwrites caller-set messageIndex/totalMessages', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + }); + await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a', messageId: 'custom-id-1', messageIndex: 99, totalMessages: 99 }, + { messageKind: 'content', message: 'b' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + const decoded = []; + for (const c of router.pushCalls) decoded.push(JSON.parse(await decryptCapturedPushBody(c.body, subKit))); + assert.equal(decoded[0].messageId, 'custom-id-1', 'caller messageId kept'); + assert.notEqual(decoded[1].messageId, decoded[0].messageId, 'auto messageId distinct'); + assert.equal(decoded[0].messageIndex, 1, 'lib overwrites caller messageIndex'); + assert.equal(decoded[0].totalMessages, 2, 'lib overwrites caller totalMessages'); + assert.equal(decoded[1].messageIndex, 2); + assert.equal(decoded[1].totalMessages, 2); + }); + + it('mid-array push failure aborts remaining pushes, no final_pushed event', async () => { + let pushIdx = 0; + const events = []; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + pushHandler: () => { + pushIdx++; + if (pushIdx === 2) { + return { ok: false, status: 502, statusText: 'Bad Gateway', async text() { return 'fail'; } }; + } + return { ok: true, status: 201, async text() { return ''; } }; + }, + }); + let caught; + try { + await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onEvent: (e) => events.push(e), + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'one' }, + { messageKind: 'content', message: 'two' }, + { messageKind: 'content', message: 'three' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + } catch (err) { + caught = err; + } + assert.ok(caught, 'mid-array failure should propagate'); + assert.equal(pushIdx, 2, 'second push attempted, third skipped'); + assert.equal(events.some(e => e.type === 'final_pushed'), false, 'no final_pushed on partial delivery'); + }); +}); +``` + +Note: this test uses a `pushHandler` knob on `createFetchRouter` that may not exist. Check `test/helpers.mjs` first; if the helper only intercepts and always returns 201, extend it (this is a one-line addition) before the test runs. The point of this step is to surface that requirement, so: + + 1. Read `test/helpers.mjs` (~200 lines). + 2. If `createFetchRouter` only supports a single static push response, add a `pushHandler` option that defaults to the current behaviour and replaces the response when provided. + 3. Re-run the tests. + +- [ ] **Step 2: Run, expect failures** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/agentic-loop.test.mjs 2>&1 | tail -40` +Expected: 3 failures — the new tests can't run because `runAgenticLoop` still expects `decision.pushPayload`. + +- [ ] **Step 3: Rip out the split machinery from `src/message-processor.js`** + +Delete these functions / consts (current line ranges): +- `DEFAULT_SPLIT_REGEX` (line 49) +- `splitOnceByRegex` (lines 51–65) +- `splitMessageIntoSentences` — **keep** the function but make it module-internal: change `export function splitMessageIntoSentences` to `function splitMessageIntoSentences` (line 89). It's still called by `runLegacyInstant` at line 783. +- `pickSplitConfig` (lines 107–186) +- `splitHookPushPayload` (lines 188–312) +- `sendChunkedPush` (lines 314–340) +- The `import { validateSplitPattern }` from validation (line 29) — already gone in Task 1; remove this import line. + +- [ ] **Step 4: Rewrite the finish/tool-request branch of `runAgenticLoop`** + +Replace the block at lines 977–999 (current code: `const isReasoning = ...; const messagesSent = isReasoning ? emitReasoning(...) : sendChunkedPush(...);`) with: + +```javascript + // 'finish' or 'tool-request' — deliver pushPayloads sequentially. + // The lib does no splitting; the hook returned the exact N pushes. + const messagesSent = await sendPushesSequentially( + decision.pushPayloads, + payload, + ctx, + sessionId, + sleep, + ); + onEvent({ + type: decision.decision === 'finish' ? 'final_pushed' : 'tool_request_pushed', + sessionId, + iteration, + messagesSent, + }); + return { status: decision.decision === 'finish' ? 'finished' : 'tool_requested', sessionId, iteration }; +``` + +Then add `sendPushesSequentially` somewhere below `runAgenticLoop` (before `assertValidDecision`): + +```javascript +/** + * Deliver `pushPayloads` sequentially via `sendPushWithMaybeBlob`, + * spacing 1500ms between consecutive pushes. Auto-fills: + * - `messageId` — only when the hook didn't set one + * - `messageIndex` — always overwritten (1-based) + * - `totalMessages` — always overwritten + * + * Throws on the first failed push; subsequent pushes are not attempted. + * + * @param {Array>} pushPayloads + * @param {Record} payload + * @param {Object} ctx + * @param {string} sessionId + * @param {(ms: number) => Promise} sleep + * @returns {Promise} + */ +async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sleep) { + const total = pushPayloads.length; + for (let i = 0; i < total; i++) { + const push = pushPayloads[i]; + // Auto-fill — see header doc. `messageId` is preserved when caller set it. + if (push.messageId === undefined) { + push.messageId = `msg_${randomUUID()}_chunk_${i}`; + } + push.messageIndex = i + 1; + push.totalMessages = total; + await sendPushWithMaybeBlob(push, payload, ctx, sessionId); + if (i < total - 1) { + await sleep(SLEEP_BETWEEN_MESSAGES_MS); + } + } + return total; +} +``` + +- [ ] **Step 5: Rewrite the LOOP_EXCEEDED diagnostic** + +Find the loop_exceeded block at lines 1002–1023. Replace `await sendChunkedPush(diagnostic, payload, ctx, sessionId, sleep);` with a single-shot call: + +```javascript + await sendPushWithMaybeBlob(diagnostic, payload, ctx, sessionId); +``` + +The diagnostic is one push by construction (it's a single `buildErrorPush(...)`), so no looping is needed. + +- [ ] **Step 6: Run the happy-path tests, expect pass** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/agentic-loop.test.mjs 2>&1 | tail -40` +Expected: the 3 new tests pass. Other previously-passing tests that use `pushPayload` (singular) all fail — that's expected. They get migrated in Task 5. + +- [ ] **Step 7: Commit** + +```bash +git add packages/rei-standard-amsg/instant/src/message-processor.js packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs packages/rei-standard-amsg/instant/test/helpers.mjs +git commit -m "$(cat <<'EOF' +feat(amsg-instant)!: 0.8.0-next.4 — sendPushesSequentially(pushPayloads) + +Hook's finish/tool-request decisions now read `pushPayloads: PushPayload[]` +and the lib delivers exactly that array in order with 1500ms spacing. +Per-push: `messageId` is auto-filled when the hook didn't set one; +`messageIndex` / `totalMessages` are always overwritten with the +array-derived values. + +Removed: splitHookPushPayload, sendChunkedPush, splitOnceByRegex, +DEFAULT_SPLIT_REGEX, pickSplitConfig. `splitMessageIntoSentences` is +still used internally by `runLegacyInstant`; no longer exported. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Simplify the reasoning auto-emit path + +Reasoning Layer-1 sentence split is gone (it used `splitHookPushPayload`); Layer-2 byte chunking stays. The auto-emit becomes: `buildReasoningPush(...)` → if `reasoningContent` exceeds `reasoningChunkBytes`, slice into N chunks and ship each with `chunkIndex` / `totalChunks`; otherwise ship as one. + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/src/message-processor.js` (replace `expandReasoningPushChunks` + `emitReasoning`) + +- [ ] **Step 1: Write the failing tests** + +Add to `test/reasoning-push.test.mjs`: + +```javascript +describe('next.4 — reasoning byte-chunking simplified', () => { + it('short reasoning ships as a single push (no chunkIndex on wire)', async () => { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('hi.', { reasoning_content: 'short thought' }), + }); + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + await handler(makeRequest(basePayload())); + const decoded = await decryptAll(router.pushCalls); + const r = decoded.find(p => p.messageKind === 'reasoning'); + assert.ok(r); + assert.equal('chunkIndex' in r, false); + assert.equal('totalChunks' in r, false); + assert.equal('messageIndex' in r, false, 'no Layer-1 split → no messageIndex'); + }); + + it('oversized reasoning gets byte-chunked into N pushes with chunkIndex/totalChunks', async () => { + const big = 'x'.repeat(5500); // > default 2000 B threshold + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('hi.', { reasoning_content: big }), + }); + const events = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onEvent: (e) => events.push(e), + }); + await handler(makeRequest(basePayload())); + const decoded = await decryptAll(router.pushCalls); + const reasoning = decoded.filter(p => p.messageKind === 'reasoning'); + assert.ok(reasoning.length >= 3, 'expected >= 3 chunks for 5500B reasoning at 2000B threshold'); + for (let i = 0; i < reasoning.length; i++) { + assert.equal(reasoning[i].chunkIndex, i + 1); + assert.equal(reasoning[i].totalChunks, reasoning.length); + } + // Reassembling yields the original + const reassembled = reasoning.map(p => p.reasoningContent).join(''); + assert.equal(reassembled, big); + // reasoning_chunked event fires exactly once + const chunkedEvts = events.filter(e => e.type === 'reasoning_chunked'); + assert.equal(chunkedEvts.length, 1); + assert.equal(chunkedEvts[0].totalChunks, reasoning.length); + }); +}); +``` + +- [ ] **Step 2: Run, expect failures (or pass — depends on whether old code still works without splitPattern)** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/reasoning-push.test.mjs 2>&1 | tail -30` +Expected: new tests likely fail because `expandReasoningPushChunks` still calls the now-deleted `splitHookPushPayload`. (Task 3 likely already broke this file — that's fine, we're about to rewrite it.) + +- [ ] **Step 3: Rewrite `expandReasoningPushChunks` and `emitReasoning`** + +Replace the entire block (lines 342–482) with: + +```javascript +// ─── Reasoning byte chunking ──────────────────────────────────────────── + +const SLEEP_BETWEEN_REASONING_CHUNKS_MS = 100; +const DEFAULT_REASONING_CHUNK_BYTES = 2000; + +/** + * Slice a ReasoningPush into one or more byte-bounded pushes. When + * `reasoningContent` UTF-8 length exceeds `reasoningChunkBytes`, the + * lib chunks at UTF-8 codepoint boundaries via + * `chunkReasoningByUtf8Bytes` and ships each chunk as its own push + * with `chunkIndex` / `totalChunks`. Otherwise ships as one. + * + * `null` for `reasoningChunkBytes` disables chunking entirely — + * oversized reasoning then either flows through `sendPushWithMaybeBlob` + * (and BlobStore) or throws `PayloadTooLargeError`. + * + * @param {Object} reasoningPush + * @param {number | null | undefined} reasoningChunkBytes + * @param {number | undefined} iteration + * @returns {Array} + */ +function sliceReasoningPush(reasoningPush, reasoningChunkBytes, iteration) { + if (reasoningChunkBytes === null) return [reasoningPush]; + const threshold = (Number.isInteger(reasoningChunkBytes) && reasoningChunkBytes >= 4) + ? reasoningChunkBytes + : DEFAULT_REASONING_CHUNK_BYTES; + + const text = typeof reasoningPush.reasoningContent === 'string' + ? reasoningPush.reasoningContent + : ''; + if (!text) return [reasoningPush]; + + const byteLen = PUSH_PAYLOAD_BYTE_ENCODER.encode(text).byteLength; + if (byteLen <= threshold) return [reasoningPush]; + + const pieces = chunkReasoningByUtf8Bytes(text, threshold); + const totalChunks = pieces.length; + const iterTag = Number.isInteger(iteration) ? iteration : 0; + return pieces.map((piece, i) => ({ + ...reasoningPush, + messageId: `msg_${randomUUID()}_iter_${iterTag}_reasoning_chunk_${i + 1}`, + reasoningContent: piece, + chunkIndex: i + 1, + totalChunks, + })); +} + +/** + * Ship a ReasoningPush, byte-chunking if oversized. Fires a single + * `reasoning_chunked` event when chunking actually splits the push. + * + * @param {Object} reasoningPush + * @param {Object} payload + * @param {Object} ctx + * @param {string} sessionId + * @param {(ms: number) => Promise} sleep + * @param {number | undefined} iteration + * @returns {Promise} + */ +async function emitReasoning(reasoningPush, payload, ctx, sessionId, sleep, iteration) { + const leaves = sliceReasoningPush(reasoningPush, ctx.reasoningChunkBytes, iteration); + + if (leaves.length > 1) { + const onEvent = typeof ctx.onEvent === 'function' ? ctx.onEvent : () => {}; + const totalBytes = typeof reasoningPush.reasoningContent === 'string' + ? PUSH_PAYLOAD_BYTE_ENCODER.encode(reasoningPush.reasoningContent).byteLength + : 0; + const evt = { type: 'reasoning_chunked', sessionId, totalChunks: leaves.length, totalBytes }; + if (Number.isInteger(iteration)) evt.iteration = iteration; + onEvent(evt); + } + + for (let i = 0; i < leaves.length; i++) { + await sendPushWithMaybeBlob(leaves[i], payload, ctx, sessionId); + if (i < leaves.length - 1) { + await sleep(SLEEP_BETWEEN_REASONING_CHUNKS_MS); + } + } + return leaves.length; +} +``` + +Note that `emitReasoning` is still called from two places: `runLegacyInstant` (around line 767) and `runAgenticLoop`'s `autoEmitReasoning` block (around line 912). Neither call site needs to change — the function signature is unchanged. + +- [ ] **Step 4: Run reasoning tests, expect pass** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/reasoning-push.test.mjs 2>&1 | tail -30` +Expected: both new tests pass. Most existing reasoning tests should also pass (they don't depend on Layer-1 split — that was an opt-in feature). Any that explicitly tested `reasoningSplitPattern` are dead and need migration in Task 6 (or deletion). + +- [ ] **Step 5: Commit** + +```bash +git add packages/rei-standard-amsg/instant/src/message-processor.js packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs +git commit -m "$(cat <<'EOF' +feat(amsg-instant)!: 0.8.0-next.4 — reasoning auto-emit drops Layer-1 split + +emitReasoning is now a single-layer transform: byte chunking via +chunkReasoningByUtf8Bytes when reasoningContent exceeds +ctx.reasoningChunkBytes, single-push passthrough otherwise. The Layer-1 +sentence split (reasoningSplitPattern) is gone — callers wanting +sentence-level reasoning chunks should disable autoEmitReasoning and +build the pushes themselves. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Migrate existing hook tests to `pushPayloads` + +The agentic loop / e2e / reasoning suites carry `pushPayload: { ... }` returns. Convert each one to `pushPayloads: [...]` (typically 1-element). Don't add new behaviour — this is mechanical migration. + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs` +- Modify: `packages/rei-standard-amsg/instant/test/e2e.test.mjs` +- Modify: `packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs` +- Modify: `packages/rei-standard-amsg/instant/examples/agentic-loop-skeleton/worker.js` + +- [ ] **Step 1: Inventory all `pushPayload:` usages in the test/example files** + +Run: +```bash +grep -rn "pushPayload:" packages/rei-standard-amsg/instant/test packages/rei-standard-amsg/instant/examples +``` + +Expected output: a list of ~15–25 occurrences across the three test files + one in the example. Note each one. + +- [ ] **Step 2: Migrate each occurrence mechanically** + +For every `pushPayload: { ... }` inside a `return { decision: 'finish'|'tool-request', pushPayload: X }` (or `({ decision: ..., pushPayload: X })` arrow return), replace with `pushPayloads: [X]`. Don't touch the X — its keys / messageKind stay verbatim. + +This is a Find/Replace pass per file, but be careful: the regex `pushPayload:` matches both the wrapper key AND any nested property called `pushPayload` (rare but possible). Use Edit per file rather than a sed sweep so you can verify each one in context. + +Example diff: + +```diff +- onLLMOutput: () => ({ +- decision: 'finish', +- pushPayload: { messageKind: 'content', message: 'hi' }, +- }), ++ onLLMOutput: () => ({ ++ decision: 'finish', ++ pushPayloads: [{ messageKind: 'content', message: 'hi' }], ++ }), +``` + +- [ ] **Step 3: Run the full suite** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/*.test.mjs 2>&1 | tail -60` +Expected: green except for: + - `test/split-pattern-hook.test.mjs` (entire suite — deleted in Task 6). + - `test/handler.test.mjs` — the `splitPattern (0.6.0)` block and the `splitMessageIntoSentences` describe block (deleted in Task 6). + +If anything else fails, stop and read the failure. It's likely a test that asserted `messageIndex` / `totalMessages` based on the old "single push" assumption — now those fields ARE auto-filled (1/1), which may surprise older assertions. Adjust the assertion to match. + +- [ ] **Step 4: Commit** + +```bash +git add packages/rei-standard-amsg/instant/test packages/rei-standard-amsg/instant/examples +git commit -m "$(cat <<'EOF' +test(amsg-instant)!: migrate hook tests to pushPayloads array + +Mechanical s/pushPayload:/pushPayloads: [/ on every finish/tool-request +hook return in agentic-loop, e2e, reasoning-push, and the +agentic-loop-skeleton example. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Delete the split-pattern test suite + the split-pattern blocks in handler.test.mjs + +`test/split-pattern-hook.test.mjs` is 1659 lines pinning behaviour that no longer exists. Delete it. + +`test/handler.test.mjs` has two relevant blocks: + 1. The `splitPattern (0.6.0)` validation block (~lines 172–225). + 2. The `splitMessageIntoSentences` describe block (~lines 288–345) — `splitMessageIntoSentences` is now module-internal, but the legacy path still uses it. The unit tests for it can stay (they import from `src/message-processor.js`)... wait, no — Step 5 of Task 1 already dropped the export. These tests import from `../src/index.js` which no longer re-exports. So they'll fail to import. + +So: delete both blocks. + +**Files:** +- Delete: `packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs` +- Modify: `packages/rei-standard-amsg/instant/test/handler.test.mjs` (drop two blocks + the now-unused import) + +- [ ] **Step 1: Delete the split-pattern suite** + +```bash +rm packages/rei-standard-amsg/instant/test/split-pattern-hook.test.mjs +``` + +- [ ] **Step 2: Find the import + block ranges in `handler.test.mjs`** + +Read `test/handler.test.mjs` lines 1–20 to find the import. It currently destructures `splitMessageIntoSentences` from `../src/index.js`. Remove it from the destructure (one of multiple imports — leave the others intact). + +- [ ] **Step 3: Delete the two blocks** + +Remove these two `describe(...)` blocks from `test/handler.test.mjs`: +1. The `splitPattern (0.6.0)` describe / its tests inside the validateInstantPayload block (the spec for those fields is now `next.4 — split-pattern fields removed` which we wrote in Task 1). +2. `describe('splitMessageIntoSentences', ...)` and everything inside it. + +Use Edit, not sed — match each block by its `describe('...')` opening + its closing `});`. Verify visually before saving. + +- [ ] **Step 4: Run the full suite, expect green** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/*.test.mjs 2>&1 | tail -30` +Expected: all tests pass. Specifically, look for the line `# tests ` / `# pass ` — the pass count should equal the test count. + +- [ ] **Step 5: Commit** + +```bash +git add packages/rei-standard-amsg/instant/test +git commit -m "$(cat <<'EOF' +test(amsg-instant)!: delete split-pattern suites + +split-pattern-hook.test.mjs (1659 lines pinning lib-side hook split +behaviour) is removed wholesale. handler.test.mjs's splitPattern +validation block and splitMessageIntoSentences unit tests are removed +— the public split helper is gone, and request-level field rejection +is covered by the new "split-pattern fields removed" describe. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Write the 13-case `pushpayloads-array.test.mjs` matrix + +The spec lists 13 fixtures (§测试要求). Most are covered already by Tasks 2/3 — but Spec asks for a single dedicated file so the contract is auditable. + +**Files:** +- Create: `packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs` + +- [ ] **Step 1: Create the file with the full matrix** + +Create `packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs`: + +```javascript +/** + * next.4 — pushPayloads-only hook decision API contract matrix. + * + * Pins the 13 fixtures from spec §测试要求. + */ + +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + createInstantHandler, + processInstantMessage, +} from '../src/index.js'; +import { + generateTestVapid, + generateTestSubscription, + createFetchRouter, + decryptCapturedPushBody, + makeLlmResponse, +} from './helpers.mjs'; + +const LLM_URL = 'https://api.example.com/v1/chat/completions'; +let vapid, subKit; +before(async () => { vapid = await generateTestVapid(); subKit = await generateTestSubscription(); }); + +function basePayload(overrides = {}) { + return { + contactName: 'Rei', + messages: [{ role: 'user', content: 'kick the loop' }], + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + sessionId: 'sess-fixture', + ...overrides, + }; +} + +function makeRequest(url, body, headers = {}) { + return new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +async function runDirect(hookReturn, ctxOverrides = {}) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('llm-output', ctxOverrides.reasoning ? { reasoning_content: ctxOverrides.reasoning } : undefined), + }); + const sleeps = []; + const events = []; + const result = await processInstantMessage(basePayload(ctxOverrides.payload), { + vapid, + fetch: router.fetch, + sleep: (ms) => { sleeps.push(ms); return Promise.resolve(); }, + onEvent: (e) => events.push(e), + onLLMOutput: () => hookReturn, + autoEmitReasoning: ctxOverrides.autoEmitReasoning, + reasoningChunkBytes: ctxOverrides.reasoningChunkBytes, + blobStore: ctxOverrides.blobStore, + requestUrl: 'http://localhost/instant', + }); + const decoded = []; + for (const c of router.pushCalls) { + decoded.push(JSON.parse(await decryptCapturedPushBody(c.body, subKit))); + } + return { result, pushes: decoded, sleeps, events, router }; +} + +async function runHandler(hookReturn, ctxOverrides = {}) { + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('llm-output'), + }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + autoEmitReasoning: false, + onLLMOutput: () => hookReturn, + }); + const res = await handler(makeRequest('http://localhost/instant', basePayload(ctxOverrides.payload))); + return { res, body: await res.json(), router }; +} + +// 1) Single-push happy path +describe('1) pushPayloads.length === 1', () => { + it('single push goes through, messageIndex=1, totalMessages=1, metadata preserved', async () => { + const { result, pushes, sleeps } = await runDirect({ + decision: 'finish', + pushPayloads: [{ + messageKind: 'content', + message: 'hi', + metadata: { trace: 'x' }, + notification: { title: 'Rei', body: 'hi' }, + }], + }, { autoEmitReasoning: false }); + assert.equal(result.status, 'finished'); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'hi'); + assert.equal(pushes[0].messageIndex, 1); + assert.equal(pushes[0].totalMessages, 1); + assert.deepEqual(pushes[0].metadata, { trace: 'x' }); + assert.deepEqual(pushes[0].notification, { title: 'Rei', body: 'hi' }); + assert.deepEqual(sleeps, []); + }); +}); + +// 2) Three-push multi-burst with 1500ms spacing +describe('2) pushPayloads.length === 3', () => { + it('ships 3 pushes with correct indices + 1500ms spacing', async () => { + const { pushes, sleeps } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a' }, + { messageKind: 'content', message: 'b' }, + { messageKind: 'content', message: 'c' }, + ], + }, { autoEmitReasoning: false }); + assert.equal(pushes.length, 3); + assert.deepEqual(pushes.map(p => p.message), ['a', 'b', 'c']); + assert.deepEqual(pushes.map(p => p.messageIndex), [1, 2, 3]); + assert.deepEqual(pushes.map(p => p.totalMessages), [3, 3, 3]); + assert.deepEqual(sleeps, [1500, 1500]); + }); +}); + +// 3) Mid-array throw +describe('3) mid-array throw aborts remaining + no final_pushed', () => { + it('push 2 fails → push 3 never sent, push_failed propagates', async () => { + let pushIdx = 0; + const router = createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: () => makeLlmResponse('whatever'), + pushHandler: () => { + pushIdx++; + if (pushIdx === 2) return { ok: false, status: 502, statusText: 'BG', async text() { return ''; } }; + return { ok: true, status: 201, async text() { return ''; } }; + }, + }); + const events = []; + let caught; + try { + await processInstantMessage(basePayload(), { + vapid, + fetch: router.fetch, + sleep: () => Promise.resolve(), + onEvent: (e) => events.push(e), + onLLMOutput: () => ({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'one' }, + { messageKind: 'content', message: 'two' }, + { messageKind: 'content', message: 'three' }, + ], + }), + autoEmitReasoning: false, + requestUrl: 'http://localhost/instant', + }); + } catch (e) { caught = e; } + assert.ok(caught); + assert.equal(pushIdx, 2); + assert.equal(events.some(e => e.type === 'final_pushed'), false); + }); +}); + +// 4) Empty array → HookError +describe('4) pushPayloads: [] → HookError', () => { + it('empty array routed to skip-push hint via HOOK_THREW', async () => { + const { res, body } = await runHandler({ decision: 'finish', pushPayloads: [] }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /use decision: skip-push to skip notification entirely/); + }); +}); + +// 5) BOTH pushPayload + pushPayloads → HookError +describe('5) pushPayload + pushPayloads → HookError', () => { + it('mixing singular and plural keys is rejected', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'a' }, + pushPayloads: [{ messageKind: 'content', message: 'b' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /use pushPayloads/); + }); +}); + +// 6) ONLY pushPayload (singular) → HookError with migration hint +describe('6) only pushPayload (singular) → HookError', () => { + it('migration message tells the caller to wrap in an array', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayload: { messageKind: 'content', message: 'a' }, + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /pushPayloads: \[yourPayload\]/); + }); +}); + +// 7) push.splitPattern → HookError +describe('7) per-push splitPattern → HookError', () => { + it('rejects splitPattern on individual push', async () => { + const { res, body } = await runHandler({ + decision: 'finish', + pushPayloads: [{ messageKind: 'content', message: 'a', splitPattern: '([。!?!?]+)' }], + }); + assert.equal(res.status, 500); + assert.equal(body.error.code, 'HOOK_THREW'); + assert.match(body.error.message, /splitPattern is removed in next\.4/); + }); +}); + +// 8) request body splitPattern → 400 INVALID_PAYLOAD_FORMAT +describe('8) request body splitPattern → 400', () => { + it('rejected pre-hook with INVALID_PAYLOAD_FORMAT', async () => { + const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, llm: () => makeLlmResponse('x') }); + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + onLLMOutput: () => ({ decision: 'finish', pushPayloads: [{ messageKind: 'content', message: 'a' }] }), + }); + const res = await handler(makeRequest('http://localhost/instant', basePayload({ splitPattern: '([。!?!?]+)' }))); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error.code, 'INVALID_PAYLOAD_FORMAT'); + assert.match(body.error.message, /splitPattern is removed in next\.4/); + }); +}); + +// 9) tool-request decision with all content kinds — lib does not police kind/decision pairing +describe('9) decision: tool-request + all content kinds', () => { + it('ships every push and returns tool_requested', async () => { + const { result, pushes } = await runDirect({ + decision: 'tool-request', + pushPayloads: [ + { messageKind: 'content', message: 'a' }, + { messageKind: 'content', message: 'b' }, + ], + }, { autoEmitReasoning: false }); + assert.equal(result.status, 'tool_requested'); + assert.equal(pushes.length, 2); + }); +}); + +// 10) finish decision containing a tool_request kind push — also accepted +describe('10) decision: finish + tool_request kind push', () => { + it('ships the tool_request push, returns finished', async () => { + const { result, pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'narration' }, + { messageKind: 'tool_request', message: '', toolCalls: [{ id: 'c1', type: 'function', function: { name: 'x' } }] }, + ], + }, { autoEmitReasoning: false }); + assert.equal(result.status, 'finished'); + assert.equal(pushes.length, 2); + assert.equal(pushes[1].messageKind, 'tool_request'); + assert.deepEqual(pushes[1].toolCalls, [{ id: 'c1', type: 'function', function: { name: 'x' } }]); + }); +}); + +// 11) messageId precedence +describe('11) messageId hook vs auto', () => { + it('hook-set messageId is preserved; unset → lib auto-fills with unique id', async () => { + const { pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a', messageId: 'hook-set-1' }, + { messageKind: 'content', message: 'b' }, + { messageKind: 'content', message: 'c', messageId: 'hook-set-3' }, + ], + }, { autoEmitReasoning: false }); + assert.equal(pushes[0].messageId, 'hook-set-1'); + assert.equal(pushes[2].messageId, 'hook-set-3'); + assert.notEqual(pushes[1].messageId, undefined); + assert.notEqual(pushes[1].messageId, pushes[0].messageId); + assert.notEqual(pushes[1].messageId, pushes[2].messageId); + }); +}); + +// 12) messageIndex/totalMessages always overwritten +describe('12) messageIndex/totalMessages overwritten', () => { + it('caller-supplied indices are clobbered with array-derived values', async () => { + const { pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'a', messageIndex: 999, totalMessages: 0 }, + { messageKind: 'content', message: 'b', messageIndex: 999, totalMessages: 0 }, + ], + }, { autoEmitReasoning: false }); + assert.deepEqual(pushes.map(p => p.messageIndex), [1, 2]); + assert.deepEqual(pushes.map(p => p.totalMessages), [2, 2]); + }); +}); + +// 13) reasoning auto-emit + pushPayloads coexist +describe('13) reasoning auto-emit precedes hook pushPayloads', () => { + it('reasoning push ships first, then hook pushes', async () => { + const { pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [ + { messageKind: 'content', message: 'final answer' }, + ], + }, { reasoning: 'thinking...' }); + assert.equal(pushes.length, 2); + assert.equal(pushes[0].messageKind, 'reasoning'); + assert.equal(pushes[0].reasoningContent, 'thinking...'); + assert.equal(pushes[1].messageKind, 'content'); + assert.equal(pushes[1].message, 'final answer'); + }); +}); +``` + +- [ ] **Step 2: Run the new file, expect green** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/pushpayloads-array.test.mjs 2>&1 | tail -30` +Expected: 13 tests pass. + +If `makeLlmResponse` isn't exported from `helpers.mjs`, copy the helper inline at the top of this file (it's small — see `test/split-pattern-hook.test.mjs` lines 99–107 as the canonical shape). Don't add a fresh helper if one already exists. + +- [ ] **Step 3: Commit** + +```bash +git add packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs +git commit -m "$(cat <<'EOF' +test(amsg-instant): 13-case pushPayloads contract matrix + +Pins the next.4 hook contract per spec §测试要求: single push, +multi-push spacing, mid-array throw, HookError rejection cases, +kind/decision decoupling, messageId precedence, index auto-fill, and +reasoning auto-emit interplay. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Rewrite README §Hook + delete splitPattern sections + +The instant README is the public-facing contract doc. Section "Hook" needs a full rewrite. The splitPattern subsections (the table + per-push override + worked examples) need to go. + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/README.md` + +- [ ] **Step 1: Read the existing Hook section** + +Read `packages/rei-standard-amsg/instant/README.md` lines 100–340 to find the Hook section and the splitPattern section. + +- [ ] **Step 2: Replace the splitPattern subsections with one short "Splitting is now caller-side" note** + +Find the heading `#### \`splitPattern\` 系列:按 \`messageKind\` 独立的分句正则(0.6.0+ / 0.8.0-next.2+)` (line ~187) and the per-push override sub-section that follows (around line ~223 — header `##### Per-push override:\`pushPayload.splitPattern\`(0.8.0-next.3+)`). Replace EVERYTHING from the splitPattern heading down through the end of the per-push override sub-section with: + +```markdown +#### 切分由 caller 负责(0.8.0-next.4 起) + +next.4 起 lib 不再做任何拆分。hook 返 `pushPayloads: PushPayload[]`,里面装的就是 lib 会原样依次发的 N 条 push。常见 caller 会自己实现: + +- 按 `\n` 或 CJK 字符之间的空格切(lookbehind / lookahead 写得出来) +- 按 inline tag(比如 `[[SEND_EMOJI: xxx]]`)独立成段 +- 切完空段 `filter`、按业务规则前后 `merge` / `split` 二阶段 +- per-chunk `notification.body` 用 sanitized 文本,`message` 字段保留 raw + +如果想要 0.7 / next.2 / next.3 的「默认 `/([。!?!?]+)/` 句切」行为,自己写: + +```js +const segments = text.split(/([。!?!?]+)/g) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter(s => s.length > 0); + +return { + decision: 'finish', + pushPayloads: segments.map((message) => ({ + messageKind: 'content', + sessionId: ctx.sessionId, + message, + notification: { title: `来自 ${ctx.contactName}`, body: message }, + })), +}; +``` + +请求 body 上的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 在 next.4 里直接 400;push 上带 `splitPattern` 抛 `HookError`。pre-release 强迫一次性改干净。 +``` + +- [ ] **Step 3: Rewrite the §Hook contract section** + +Find the section that describes `onLLMOutput`'s return shape (around line ~150 — search for `decision: 'finish'`). Replace the 4-decision summary code block with: + +```markdown +```ts +type HookDecision = + | { decision: 'finish'; pushPayloads: PushPayload[] } + | { decision: 'tool-request'; pushPayloads: PushPayload[] } + | { decision: 'continue'; nextHistory: ChatMessage[] } + | { decision: 'skip-push' }; +``` + +**没有单数 `pushPayload` 字段了。** 1 条就 `[push]`,3 条就 `[a, b, c]`。 + +lib 给每个 push 自动补这 3 个机械字段(hook 自己设 `messageId` 会被尊重,其余 2 个无论 hook 写什么都被覆盖): + +| 字段 | 自动补充行为 | +|----------------|-----------------------------------------------| +| `messageId` | 未设时 lib 用 `msg__chunk_` 填上 | +| `messageIndex` | 永远是 1-based 数组下标 + 1(hook 写啥都覆盖)| +| `totalMessages`| 永远是 `pushPayloads.length` | + +剩下所有字段(`messageKind` / `notification` / `metadata` / `messageKind` 特定字段 / 等)都是 per-push,caller 完全控制。 +``` + +- [ ] **Step 4: Add three worked examples** + +Right below the contract section, add: + +````markdown +##### 例 1:单 push + +```js +return { + decision: 'finish', + pushPayloads: [{ + messageKind: 'content', + sessionId: ctx.sessionId, + message: 'Hello', + notification: { title: 'Sully', body: 'Hello' }, + }], +}; +``` + +##### 例 2:3 chunk content + 不同 notification.body(banner 显示 sanitized,bubble 显示 raw) + +```js +return { + decision: 'finish', + pushPayloads: [ + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '你看', + notification: { title: '来自 Sully', body: '你看' }, + }, + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '[[SEND_EMOJI: 笑]]', // raw 给客户端 app + notification: { title: '来自 Sully', body: '[表情:笑]' }, // sanitized 给 banner + }, + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '我没事的', + notification: { title: '来自 Sully', body: '我没事的' }, + }, + ], +}; +``` + +##### 例 3:tool-request 混 content + 多 toolCalls + +```js +return { + decision: 'tool-request', + pushPayloads: [ + { + messageKind: 'content', + sessionId: ctx.sessionId, + message: '让我同时查日记和天气', + notification: { title: '来自 Sully', body: '让我同时查日记和天气' }, + }, + { + messageKind: 'tool_request', + sessionId: ctx.sessionId, + message: '', + toolCalls: [ + { id: 'rd_1', type: 'function', function: { name: 'notion_read_diary', arguments: '{"date":"2024-05-21"}' } }, + { id: 'ws_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"北京天气"}' } }, + ], + // 无 notification → 不弹 OS 横幅 + }, + ], +}; +``` + +decision 跟 push 内容的 `messageKind` 分布完全解耦——lib 不检查「`tool-request` decision 是不是必须含 `tool_request` push」之类的搭配,hook 想怎么组合就怎么组合。 +```` + +- [ ] **Step 5: Drop the typedef block's `splitPattern?:` line at line ~135** + +In the section showing the request payload TypeScript-ish typedef (around line 130–145), remove the `splitPattern?: ...` line. Leave neighbouring fields alone. + +- [ ] **Step 6: Update the "0.6 byte-level compat" note** + +Around line 820 the README says: "Legacy 路径,字节级与 v0.6 一致(同 13 字段 payload、同 1500 ms 间隔、同 `splitPattern`、同 `onEvent` 事件)". Drop the `同 splitPattern` reference (legacy still uses default sentence regex internally — its behaviour is unchanged for callers, but the public `splitPattern` knob is gone). + +- [ ] **Step 7: Commit** + +```bash +git add packages/rei-standard-amsg/instant/README.md +git commit -m "$(cat <<'EOF' +docs(amsg-instant)!: rewrite README §Hook for pushPayloads-only API + +Drop the splitPattern / reasoningSplitPattern / errorSplitPattern +subsections + per-push override sub-section (~150 lines). Add the new +pushPayloads contract block, the messageId/messageIndex/totalMessages +auto-fill table, and three worked examples (single push, 3-chunk +content with per-chunk notification.body, tool-request mixing content +and multi-toolCalls). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Add CHANGELOG entry + migration guide + version bump + +**Files:** +- Modify: `packages/rei-standard-amsg/instant/CHANGELOG.md` (prepend new section) +- Create: `packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md` +- Modify: `packages/rei-standard-amsg/instant/package.json` (`0.8.0-next.3` → `0.8.0-next.4`) + +- [ ] **Step 1: Prepend the CHANGELOG entry** + +Add to the very top of `packages/rei-standard-amsg/instant/CHANGELOG.md` (above the existing `## 0.8.0-next.3` section): + +```markdown +## 0.8.0-next.4 — BREAKING: pushPayloads-only hook decision API (pre-release) + +Install with `npm install @rei-standard/amsg-instant@next`. Pre-release — breaking on purpose. 见 [`docs/migration-0.8.0-next.4.md`](./docs/migration-0.8.0-next.4.md) 完整迁移指南. + +### Removed + +- `decision.pushPayload` (singular). Replaced by `decision.pushPayloads: PushPayload[]`. +- Request-body fields `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` — rejected with 400 `INVALID_PAYLOAD_FORMAT` and a migration hint pointing at `pushPayloads`. +- `pushPayload.splitPattern` per-push override (next.3 only) — rejected with `HookError`. +- Public export `splitMessageIntoSentences` — used to be exported from `@rei-standard/amsg-instant` for hook authors who wanted "the same default split as the legacy path". The legacy path still uses it internally; hook authors implement their own split. +- Internal helpers `splitHookPushPayload` / `splitOnceByRegex` / `pickSplitConfig` / `validateSplitPattern` / `validatePerKindSplitPatterns` / `DEFAULT_SPLIT_REGEX` / `SPLIT_PATTERN_MAX_*` — all gone. +- The two-layer reasoning cascade collapsed to one layer (byte chunking). The Layer-1 sentence split via `reasoningSplitPattern` is gone with the field. + +### Changed + +- `runAgenticLoop`'s finish / tool-request branch now reads `decision.pushPayloads` and ships each push via `sendPushWithMaybeBlob` with `SLEEP_BETWEEN_MESSAGES_MS` (1500ms) between consecutive pushes. Per-push: `messageId` is auto-filled when absent (`msg__chunk_`); `messageIndex` / `totalMessages` are always overwritten with array-derived values. +- LOOP_EXCEEDED diagnostic is now a single `sendPushWithMaybeBlob` call (no looping needed — the diagnostic is one push). +- Reasoning auto-emit (`autoEmitReasoning: true`, default): now a single transform. Short reasoning → 1 push; oversized → N byte-chunked pushes with `chunkIndex` / `totalChunks` (Layer-2 only). + +### Unchanged + +- Legacy v0.6 compat path (no `onLLMOutput`) still splits raw LLM text by sentence regex and ships sequential pushes — byte-level identical to v0.6. The public `splitPattern` knob on the request body is gone, but the path's internal behaviour is preserved (default regex `/([。!?!?]+)/`). +- HOOK_THREW handling (single-shot diagnostic, best-effort delivery), blob envelope, `maxLoopIterations`, `autoEmitReasoning`, `reasoningChunkBytes`, all 4 decisions (`finish` / `tool-request` / `continue` / `skip-push`). +- VAPID / push subscription / `apiKey` are still not exposed to the hook. +- HTTP status code mapping unchanged. + +### Migration cheat sheet + +| next.3 | next.4 | +|-------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| `return { decision: 'finish', pushPayload: { ... } }` | `return { decision: 'finish', pushPayloads: [{ ... }] }` | +| Request body `splitPattern: '([。!?!?]+)'` | Implement the split in your hook; return one push per segment | +| `pushPayload.splitPattern: null` (per-push disable from next.3) | Return `pushPayloads: [singleUnsplit]` | +| `reasoningSplitPattern` request field | Set `autoEmitReasoning: false`, build N reasoning pushes yourself with `buildReasoningPush(...)`, include them at the start of `pushPayloads` | + +### Why breaking in pre-release + +The `0.8.0-next.*` series is pre-1.0 unstable. next.2 + next.3 stacked two overlapping mechanisms (lib-side splitPattern auto-split + hook-side pushPayload singular). next.4 collapses both into one (caller returns the exact pushes it wants sent) before 1.0 freezes the public surface. +``` + +- [ ] **Step 2: Create the migration guide doc** + +Create `packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md` with the expanded migration content — copy the §Migration cheat sheet table from the CHANGELOG, plus add the three full worked examples from the README. The guide is the long-form companion; the CHANGELOG points to it. + +The full content should mirror the spec's §迁移指南 block verbatim — copy that section in CN/EN per the spec's authorial voice. + +- [ ] **Step 3: Bump the version** + +In `packages/rei-standard-amsg/instant/package.json`: + +```diff +- "version": "0.8.0-next.3", ++ "version": "0.8.0-next.4", +``` + +Don't update `dependencies` — `@rei-standard/amsg-shared@0.1.0-next.3` is still the right peer. + +- [ ] **Step 4: Verify build still works** + +Run: `cd packages/rei-standard-amsg/instant && npm run build 2>&1 | tail -20` +Expected: tsup builds dist/ cleanly (the build doesn't read package.json version for code generation, but a sanity check catches any stray `splitMessageIntoSentences` import we missed). + +If `npm run build` exits non-zero, read the error carefully — most likely cause is a stale import / re-export that escaped Task 1. + +- [ ] **Step 5: Commit** + +```bash +git add packages/rei-standard-amsg/instant/CHANGELOG.md packages/rei-standard-amsg/instant/docs/migration-0.8.0-next.4.md packages/rei-standard-amsg/instant/package.json +git commit -m "$(cat <<'EOF' +docs(amsg-instant)!: 0.8.0-next.4 CHANGELOG + migration guide + version bump + +Pre-release breaking change documented end-to-end: +- CHANGELOG.md: BREAKING section enumerating Removed / Changed / + Unchanged + migration cheat sheet +- docs/migration-0.8.0-next.4.md: long-form companion with worked + examples for the 3 most common migrations +- package.json: 0.8.0-next.3 → 0.8.0-next.4 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Full-suite verification + final commit + +**Files:** +- None (verification only — any drift surfaces a fixup commit) + +- [ ] **Step 1: Run the full suite from scratch** + +Run: `cd packages/rei-standard-amsg/instant && node --test test/*.test.mjs 2>&1 | tail -40` +Expected: green. Count `# pass ` and `# fail 0`. + +- [ ] **Step 2: Build the dist bundle** + +Run: `cd packages/rei-standard-amsg/instant && npm run build 2>&1 | tail -20` +Expected: tsup writes dist/ without errors. Look at `dist/index.mjs` and grep: + +```bash +grep -n "splitHookPushPayload\|splitMessageIntoSentences\|validateSplitPattern\|sendChunkedPush\|DEFAULT_SPLIT_REGEX" packages/rei-standard-amsg/instant/dist/index.mjs | head -10 +``` + +`splitMessageIntoSentences` may still appear (legacy path's internal use). All others MUST be gone. If any leak, that's a stale import or re-export — fix the source file and re-run from Step 1. + +- [ ] **Step 3: Confirm no stray usages elsewhere in the monorepo** + +Run: +```bash +grep -rn "pushPayload:" packages/rei-standard-amsg --include="*.js" --include="*.mjs" --include="*.md" | grep -v "node_modules\|dist\|CHANGELOG\|docs/migration" +``` + +Expected: empty (modulo the CHANGELOG / migration docs themselves, which legitimately reference the old field name in the `next.3` history section). + +- [ ] **Step 4: Run the broader instant `agentic-loop-skeleton` example sanity check** + +```bash +cd packages/rei-standard-amsg/instant && node -e "import('./examples/agentic-loop-skeleton/worker.js').then(m => console.log(Object.keys(m)))" 2>&1 +``` + +Expected: the example imports cleanly (`{ default: ... }`). If it errors with "pushPayload is removed", Task 5 missed the example file. Fix and re-commit. + +- [ ] **Step 5: One final smoke test — start node + drive a synthetic request** + +This is optional but cheap. Skip if Steps 1–4 are all green. + +- [ ] **Step 6: Confirm clean git status** + +```bash +git status +``` + +Expected: clean working tree. All 9 prior commits visible in `git log`. If anything is uncommitted, look at it carefully — should be either fixup-worthy or actually intended. + +- [ ] **Step 7: (Optional) Squash-merge prep** + +The 9 commits track the implementation order for review. If the eventual PR is squash-merged, the squash message should be `feat(amsg-instant)!: 0.8.0-next.4 — pushPayloads-only hook decision API` with the CHANGELOG entry as the body. Do not squash on this branch unless the user explicitly asks — leave the commit history clean for `git log` traceability. + +--- + +## Self-review + +**Spec coverage:** +- §背景 痛点 1(split 规则正则表达不完)→ Task 8 README rewrite says caller does its own split; Task 7 fixtures #1, #2 exercise multi-push. +- §背景 痛点 2(per-chunk 字段独立性)→ Task 7 fixture #1 asserts `notification` survives per-push; Task 5 migration; Task 8 worked example #2. +- §API definition → Task 2 + Task 3 implement. +- §例 1 / 2 / 3 → Task 7 fixtures #1, #13, #9 — note example 3 is mirrored by fixture #9 (tool-request decision + mixed kinds). +- §校验规则 #1 (empty array) → Task 2 step 1 fixture; Task 7 fixture #4. +- §校验规则 #2 (both pushPayload + pushPayloads) → Task 2; Task 7 fixture #5. +- §校验规则 #3 (only pushPayload, no auto-wrap) → Task 2; Task 7 fixture #6. +- §校验规则 #4 (push.splitPattern) → Task 2; Task 7 fixture #7. +- §校验规则 #5 (request body split-pattern fields) → Task 1; Task 7 fixture #8. +- §lib 自动补充字段 → Task 3 `sendPushesSequentially`; Task 7 fixtures #11 + #12. +- §发送行为 → Task 3 `sendPushesSequentially` (sequential, 1500ms gap, mid-throw abort); Task 7 fixtures #2, #3. +- §reasoning push 路径独立 → Task 4 `sliceReasoningPush` / `emitReasoning`; Task 4 step 1 fixtures; Task 7 fixture #13. +- §删除 `splitPattern` 相关 → Task 1 (validation) + Task 3 (helpers gone) + Task 6 (tests gone). +- §迁移指南 → Task 9 CHANGELOG + migration doc. +- §测试要求 13 cases → Task 7 maps each one. +- §文档要求 → Task 8 (README) + Task 9 (CHANGELOG + migration). + +**Placeholder scan:** No TBDs. Every "implement appropriate X" step has the actual code. Every "similar to Task N" mention points at one specific prior task and re-states the relevant bits. + +**Type consistency:** `pushPayloads` everywhere (plural). `sendPushesSequentially` referenced consistently. `messageId` / `messageIndex` / `totalMessages` field names are identical across plan, code, and test sections. `HookError` (`{ cause? }` constructor) used uniformly. + +**Risk:** +- The `pushHandler` knob on `createFetchRouter` referenced in Task 3 step 1 / Task 7 fixture #3 may not exist in `helpers.mjs`. Plan says "extend if missing" — that's a one-line addition that's already inside Task 3's step. +- Task 8 step 3 says "find section showing return shape … around line 150". If the README diverges from my count, the executor should re-locate via `grep -n "decision: 'finish'" README.md`. The replacement content is exact regardless of where it lands. diff --git a/package-lock.json b/package-lock.json index eeded58..55f8db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1850,10 +1850,10 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.3.0-next.1", + "version": "2.3.0-next.2", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0" + "@rei-standard/amsg-shared": "0.1.0-next.4" }, "devDependencies": { "tsup": "^8.0.0", @@ -1863,21 +1863,12 @@ "node": ">=20" } }, - "packages/rei-standard-amsg/client/node_modules/@rei-standard/amsg-shared": { - "version": "0.1.0-next.0", - "resolved": "https://registry.npmjs.org/@rei-standard/amsg-shared/-/amsg-shared-0.1.0-next.0.tgz", - "integrity": "sha512-zgGGGWUh+h8zcSOYghh7vBrMQCQgSbgl0TeShGDH/LT8yWlViRrrFTJHCaVsmqtRu6v9nDHedMDavqv2NXz8JQ==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.6", + "version": "0.8.0-next.7", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.3" + "@rei-standard/amsg-shared": "0.1.0-next.4" }, "devDependencies": { "tsup": "^8.0.0", @@ -1889,11 +1880,11 @@ }, "packages/rei-standard-amsg/server": { "name": "@rei-standard/amsg-server", - "version": "2.4.0-next.1", + "version": "2.4.0-next.2", "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", - "@rei-standard/amsg-shared": "0.1.0-next.0", + "@rei-standard/amsg-shared": "0.1.0-next.4", "web-push": "^3.6.7" }, "devDependencies": { @@ -1918,18 +1909,9 @@ } } }, - "packages/rei-standard-amsg/server/node_modules/@rei-standard/amsg-shared": { - "version": "0.1.0-next.0", - "resolved": "https://registry.npmjs.org/@rei-standard/amsg-shared/-/amsg-shared-0.1.0-next.0.tgz", - "integrity": "sha512-zgGGGWUh+h8zcSOYghh7vBrMQCQgSbgl0TeShGDH/LT8yWlViRrrFTJHCaVsmqtRu6v9nDHedMDavqv2NXz8JQ==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "packages/rei-standard-amsg/shared": { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.3", + "version": "0.1.0-next.4", "license": "MIT", "devDependencies": { "tsup": "^8.0.0", @@ -1941,10 +1923,10 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.2", + "version": "2.1.0-next.4", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.0" + "@rei-standard/amsg-shared": "0.1.0-next.4" }, "devDependencies": { "tsup": "^8.0.0", @@ -1953,15 +1935,6 @@ "engines": { "node": ">=20" } - }, - "packages/rei-standard-amsg/sw/node_modules/@rei-standard/amsg-shared": { - "version": "0.1.0-next.0", - "resolved": "https://registry.npmjs.org/@rei-standard/amsg-shared/-/amsg-shared-0.1.0-next.0.tgz", - "integrity": "sha512-zgGGGWUh+h8zcSOYghh7vBrMQCQgSbgl0TeShGDH/LT8yWlViRrrFTJHCaVsmqtRu6v9nDHedMDavqv2NXz8JQ==", - "license": "MIT", - "engines": { - "node": ">=20" - } } } } diff --git a/packages/rei-standard-amsg/instant/pnpm-lock.yaml b/packages/rei-standard-amsg/instant/pnpm-lock.yaml new file mode 100644 index 0000000..f3e8c7e --- /dev/null +++ b/packages/rei-standard-amsg/instant/pnpm-lock.yaml @@ -0,0 +1,920 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@rei-standard/amsg-shared': + specifier: 0.1.0-next.3 + version: 0.1.0-next.3 + devDependencies: + tsup: + specifier: ^8.0.0 + version: 8.5.1(typescript@5.9.3) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rei-standard/amsg-shared@0.1.0-next.3': + resolution: {integrity: sha512-w9x2x1ZT9+l/864z+pSlAx6WiujNolpEsVhGYWFGJweW6XVoS4bgYzKfSkTscXzjgFsv/+qmAnywULX0SDKbtw==} + engines: {node: '>=20'} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rei-standard/amsg-shared@0.1.0-next.3': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/estree@1.0.8': {} + + acorn@8.16.0: {} + + any-promise@1.3.0: {} + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.4 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1: + dependencies: + lilconfig: 3.1.3 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + source-map@0.7.6: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.16 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1 + resolve-from: 5.0.0 + rollup: 4.60.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} diff --git a/packages/rei-standard-amsg/instant/pnpm-workspace.yaml b/packages/rei-standard-amsg/instant/pnpm-workspace.yaml new file mode 100644 index 0000000..00f6fc4 --- /dev/null +++ b/packages/rei-standard-amsg/instant/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: set this to true or false diff --git a/packages/rei-standard-amsg/instant/src/utils.js b/packages/rei-standard-amsg/instant/src/utils.js index 24da180..e3f44e9 100644 --- a/packages/rei-standard-amsg/instant/src/utils.js +++ b/packages/rei-standard-amsg/instant/src/utils.js @@ -12,6 +12,9 @@ const TEXT_ENCODER = new TextEncoder(); const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false }); +import { toUint8, concatBytes, base64UrlToBytes } from '@rei-standard/amsg-shared'; +export { toUint8, concatBytes, base64UrlToBytes }; + /** UTF-8 encode a string into a Uint8Array. */ export function utf8(str) { return TEXT_ENCODER.encode(String(str)); @@ -22,26 +25,6 @@ export function utf8Decode(buf) { return TEXT_DECODER.decode(toUint8(buf)); } -/** Coerce ArrayBuffer | Uint8Array | view → Uint8Array (no copy when possible). */ -export function toUint8(buf) { - if (buf instanceof Uint8Array) return buf; - if (buf instanceof ArrayBuffer) return new Uint8Array(buf); - if (ArrayBuffer.isView(buf)) return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); - throw new TypeError('Expected ArrayBuffer / Uint8Array'); -} - -/** Concatenate Uint8Arrays into a single Uint8Array. */ -export function concatBytes(...chunks) { - let total = 0; - for (const c of chunks) total += c.byteLength; - const out = new Uint8Array(total); - let offset = 0; - for (const c of chunks) { - out.set(c instanceof Uint8Array ? c : new Uint8Array(c.buffer || c), offset); - offset += c.byteLength; - } - return out; -} /** Encode bytes as base64url (no padding). */ export function bytesToBase64Url(buf) { @@ -57,18 +40,6 @@ export function bytesToBase64Url(buf) { return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } -/** Decode base64url (with or without padding) → Uint8Array. */ -export function base64UrlToBytes(input) { - const s = String(input).replace(/-/g, '+').replace(/_/g, '/'); - const pad = (4 - (s.length % 4)) % 4; - const padded = s + '='.repeat(pad); - const bin = (typeof atob === 'function') - ? atob(padded) - : Buffer.from(padded, 'base64').toString('binary'); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} /** Encode a JSON-serializable value as base64url (UTF-8 JSON). */ export function jsonToBase64Url(value) { diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index 7b05ee9..7b3e8af 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -39,6 +39,55 @@ navigator.serviceWorker.addEventListener('message', (e) => { }); ``` +### 通知显示策略 (Notification Rendering) + +默认情况下: +- `content` 和老式 payload:自动弹系统通知。 +- `reasoning` / `tool_request` / `error`:不弹通知,只触发 client 事件。 + +通过 `payload.notification.show`,你可以显式覆盖这个默认行为。此字段由服务端或产生 payload 时指定: + +- `"auto"` 或不传:保持默认行为。 +- `"always"`:强制弹系统通知,无视 `messageKind`。 +- `"when-hidden"`:仅当没有 `visibilityState === "visible"` 的客户端时才弹系统通知。如果应用在前台,则静默。 +- `false`:强制不弹通知,即使是 `content`。适合完全交给应用自行接管或自绘弹窗的场景。 + +当设置了弹通知时,通知文案完全由 `payload.notification` 决定(支持 `title`, `body`, `icon`, `badge`, `tag`, `data` 等字段)。如果缺省,会后备到 payload 根级属性。 + +#### 场景示例 + +**1. tool_request 需要用户处理** +某些 Agent loop 跑到 `tool_request` 时需要用户在界面上确认或执行。由于默认 `tool_request` 不弹通知,用户如果在后台可能会漏掉: + +```json +{ + "messageKind": "tool_request", + "sessionId": "...", + "toolCalls": [], + "notification": { + "show": "when-hidden", + "title": "需要继续处理", + "body": "点开应用继续完成工具调用" + } +} +``` + +**2. Content 消息完全由前端接管** +应用层想在页面前台做非常定制的 Toast,不想弹系统级别通知: + +```json +{ + "messageKind": "content", + "message": "...", + "notification": { + "show": false + } +} +``` + +> **注意:对于 multipart 传输** +> 当 payload 通过 `_multipart` 分片时,未收齐前不仅不派发业务事件,也**绝不**弹系统通知。收齐并还原为原始 payload 后,再按原始 payload 的 `notification.show` 策略执行判定。 + ### Blob envelope 当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。 @@ -101,7 +150,7 @@ TTL 到期仍未收齐时,SW 会清理 pending 并广播: ### 升级注意事项 -- 想给 `reasoning` / `tool_request` / `error` 也弹通知的业务:必须自行在 app 内监听上面的 postMessage 事件、调 `Notification` 或 `registration.showNotification`。SW 默认不再为它们弹通知。 +- 想给 `reasoning` / `tool_request` / `error` 弹通知的业务:SW 默认不再为它们弹通知,但可以通过设置 `payload.notification.show = "always"` 或 `"when-hidden"` 来让 SW 在包层直接弹通知。无需再强求在 app 内自绘。 - 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑;next 版本只会把完整还原后的 reasoning payload 发给 client。 - 客户端代码继续兼容只有 `installReiSW` + `REI_SW_MESSAGE_TYPE`(队列)的 2.0.x 写法——新增导出不破坏既有 API。 - 想拿到 push 类型相关的 TS 类型:从 `@rei-standard/amsg-shared` 引 `AmsgPush` 等类型(本包通过 JSDoc 引用同一份类型)。 From 027a9cd22b42af71e4b3f916027456cef627fba8 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Mon, 25 May 2026 16:48:00 +0800 Subject: [PATCH 30/33] feat(amsg-instant): add waitUntil lifecycle support --- bump.mjs | 2 +- package-lock.json | 2 +- .../rei-standard-amsg/instant/CHANGELOG.md | 6 + packages/rei-standard-amsg/instant/README.md | 23 +- .../rei-standard-amsg/instant/package.json | 2 +- .../instant/src/adapters/cloudflare.js | 10 +- .../instant/src/adapters/netlify.js | 16 +- .../instant/src/adapters/node.js | 31 ++- .../instant/src/adapters/vercel.js | 15 +- .../rei-standard-amsg/instant/src/index.js | 41 ++- .../instant/test/cloudflare-adapter.test.mjs | 239 ++++++++++++++++++ 11 files changed, 358 insertions(+), 29 deletions(-) create mode 100644 packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs diff --git a/bump.mjs b/bump.mjs index a8458d5..3d98f01 100644 --- a/bump.mjs +++ b/bump.mjs @@ -13,6 +13,6 @@ function updatePkg(pkgPath, version, sharedDep) { updatePkg('packages/rei-standard-amsg/shared/package.json', '0.1.0-next.4', null); updatePkg('packages/rei-standard-amsg/sw/package.json', '2.1.0-next.4', '0.1.0-next.4'); -updatePkg('packages/rei-standard-amsg/instant/package.json', '0.8.0-next.7', '0.1.0-next.4'); +updatePkg('packages/rei-standard-amsg/instant/package.json', '0.8.0-next.8', '0.1.0-next.4'); updatePkg('packages/rei-standard-amsg/client/package.json', '2.3.0-next.2', '0.1.0-next.4'); updatePkg('packages/rei-standard-amsg/server/package.json', '2.4.0-next.2', '0.1.0-next.4'); diff --git a/package-lock.json b/package-lock.json index 55f8db0..2bd7328 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1865,7 +1865,7 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.7", + "version": "0.8.0-next.8", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0-next.4" diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index c0a9a97..15fff98 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.0-next.8 — waitUntil lifecycle support (pre-release) + +- `waitUntil` 注册的是后台生命周期保护 promise;主流程失败仍由 handler 转成原有 HTTP 错误响应,同时通过 `wait_until_rejected` 事件记录,不额外制造 rejected background promise。 +- Cloudflare Workers:`createCloudflareWorker.fetch` 现在接收第三个 `ExecutionContext` 参数,并把主回复链路(LLM 生成、切段、逐条 Web Push)交给 `ctx.waitUntil` 保护。直接把 `createInstantHandler(...)` 挂成 Worker module `fetch` 时,也会识别 Cloudflare 传入的 `(request, env, ctx)`。 +- 其他运行时:`createInstantHandler` 新增通用 `waitUntil` 生命周期入口;Netlify / Vercel Edge adapters 会透传第二个 context 参数;Node adapter 新增可选 `toNodeHandler(fetchHandler, { waitUntil | runtime | getRuntime })`,方便宿主有生命周期钩子时统一保护主回复链路。 + ## 0.8.0-next.7 — Dependency bump (pre-release) - 依赖更新:升级 `@rei-standard/amsg-shared` 到 `0.1.0-next.4` 以获取最新的 `notification.show` 和 `multipart` 相关工具。删除了项目内的 `base64` / `concat` 工具函数,迁移使用 `amsg-shared` 导出的底层工具,提升代码可维护性。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index c9c33b8..7611346 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -77,9 +77,11 @@ createInstantHandler({ ## API -### `createInstantHandler(options) → (request: Request) => Promise` +### `createInstantHandler(options) → (request: Request, env?, ctx?) => Promise` 返回标准 Web Fetch API handler。直接挂到 Cloudflare Workers / Deno Deploy / Vercel Edge / Bun,或用下方四个 adapter 接到 Node / Netlify。 +如果运行时提供 `waitUntil`,可以通过请求 context 或 `options.waitUntil` 交给 handler, +主回复链路(LLM 生成 → 切段 → 逐条 Web Push)会被注册进去。 ```js import { createInstantHandler } from '@rei-standard/amsg-instant'; @@ -845,6 +847,12 @@ export default createCloudflareWorker((env) => ({ })); ``` +`createCloudflareWorker` 会接住 Workers 的第三个参数 `ctx`,并把主回复链路 +(LLM 生成 → 切段 → 逐条 Web Push)注册到 `ctx.waitUntil`。这样浏览器关掉、 +页面切后台导致 HTTP 连接断开时,Worker 仍会尽力把这一轮推送跑完。直接把 +`createInstantHandler(...)` 挂成 Worker module `fetch` 也支持同样的 `(request, env, ctx)` +形态。 + ```toml # wrangler.toml name = "amsg-instant" @@ -882,6 +890,10 @@ app.post('/instant', toNodeHandler(instantHandler)); app.listen(3000); ``` +Node / Express 本身没有标准 `waitUntil`。如果你的宿主环境额外提供了生命周期钩子, +可以用 `toNodeHandler(instantHandler, { waitUntil })`,或在需要按请求动态取 context 时 +用 `{ getRuntime(req, res) { return { waitUntil }; } }`。 + ### Netlify Functions > Netlify Functions 默认仍是 Node 18,0.3.0 起 `adapters/node` 在请求入口处自动检测并 polyfill `globalThis.crypto`,无需 caller 做任何事。如果想原生 Web Crypto,把 Function 切到 Netlify Edge 即可。 @@ -904,6 +916,9 @@ export default toNetlifyHandler(handler); export const config = { path: '/api/v1/instant' }; ``` +`toNetlifyHandler` 会把 Netlify 的第二个 `context` 参数透传给 handler;当平台提供 +`context.waitUntil` 时,主回复链路会自动挂进去。 + ### Vercel Functions(Edge Runtime) ```js @@ -925,6 +940,10 @@ const handler = createInstantHandler({ export default toVercelEdgeHandler(handler); ``` +`toVercelEdgeHandler` 会透传第二个 context 参数,适配暴露 `context.waitUntil` 的运行时形态。 +如果你使用 Vercel 的 `@vercel/functions` helper,则直接把它传给 +`createInstantHandler({ ..., waitUntil })` 即可,库本身不会强依赖这个包。 + ## 浏览器端调用 使用 `@rei-standard/amsg-client` 的 `sendInstant()`,并把 `instantEncryption: false` 打开: @@ -964,7 +983,7 @@ await client.sendInstant({ 子路径: - `@rei-standard/amsg-instant/adapters/cloudflare` — `createCloudflareWorker(optionsBuilder)` -- `@rei-standard/amsg-instant/adapters/node` — `toNodeHandler(fetchHandler)` +- `@rei-standard/amsg-instant/adapters/node` — `toNodeHandler(fetchHandler, options?)` - `@rei-standard/amsg-instant/adapters/netlify` — `toNetlifyHandler(fetchHandler)` - `@rei-standard/amsg-instant/adapters/vercel` — `toVercelEdgeHandler(fetchHandler)` / `toVercelNodeHandler` diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index e52d2e6..91a829a 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.7", + "version": "0.8.0-next.8", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/adapters/cloudflare.js b/packages/rei-standard-amsg/instant/src/adapters/cloudflare.js index 81ba951..beb1b41 100644 --- a/packages/rei-standard-amsg/instant/src/adapters/cloudflare.js +++ b/packages/rei-standard-amsg/instant/src/adapters/cloudflare.js @@ -48,19 +48,21 @@ import { createInstantHandler } from '../index.js'; * handler the first time a request arrives. The factory receives the * Workers `env` binding so secrets can be read at request time rather * than at module-init time (which is required by Workers when secrets - * are scoped per environment). + * are scoped per environment). The request-scoped `ExecutionContext` + * is forwarded into the handler so the main LLM → split → push pipeline + * is registered with `ctx.waitUntil` when Cloudflare provides it. * * @param {(env: Record) => import('../index.js').InstantHandlerOptions} optionsBuilder - * @returns {{ fetch: (request: Request, env: Record) => Promise }} + * @returns {{ fetch: (request: Request, env: Record, ctx?: { waitUntil?: (work: Promise) => void }) => Promise }} */ export function createCloudflareWorker(optionsBuilder) { let handler = null; return { - async fetch(request, env) { + async fetch(request, env, ctx) { if (!handler) { handler = createInstantHandler(optionsBuilder(env || {})); } - return handler(request); + return handler(request, ctx); } }; } diff --git a/packages/rei-standard-amsg/instant/src/adapters/netlify.js b/packages/rei-standard-amsg/instant/src/adapters/netlify.js index 8f3999c..779122e 100644 --- a/packages/rei-standard-amsg/instant/src/adapters/netlify.js +++ b/packages/rei-standard-amsg/instant/src/adapters/netlify.js @@ -2,10 +2,10 @@ * Netlify Functions adapter for @rei-standard/amsg-instant. * * Netlify Functions (v2, Fetch-API-style) accept a handler of the - * shape `(req: Request) => Response | Promise`, which is - * exactly the shape produced by `createInstantHandler`. This file is - * a thin pass-through for symmetry with the other adapters and to - * give downstream a consistent import path. + * shape `(req: Request, context: Context) => Response | Promise`, + * which is compatible with the shape produced by `createInstantHandler`. + * This adapter forwards the Netlify context so `context.waitUntil` can + * protect the main LLM → split → push pipeline when the platform provides it. * * Usage: * // netlify/functions/instant.js @@ -18,12 +18,12 @@ */ /** - * @param {(request: Request) => Promise} fetchHandler - * @returns {(req: Request) => Promise} + * @param {(request: Request, runtime?: { waitUntil?: (work: Promise) => void }) => Promise} fetchHandler + * @returns {(req: Request, context?: { waitUntil?: (work: Promise) => void }) => Promise} */ export function toNetlifyHandler(fetchHandler) { - return async function netlifyHandler(req) { - return fetchHandler(req); + return async function netlifyHandler(req, context) { + return fetchHandler(req, context); }; } diff --git a/packages/rei-standard-amsg/instant/src/adapters/node.js b/packages/rei-standard-amsg/instant/src/adapters/node.js index 3975c2e..6d4d96f 100644 --- a/packages/rei-standard-amsg/instant/src/adapters/node.js +++ b/packages/rei-standard-amsg/instant/src/adapters/node.js @@ -18,6 +18,10 @@ * `express.json()` on this route. Conversely, ensure no body parser * has already consumed the stream before this middleware runs. * - Headers are forwarded case-insensitively via the Fetch API. + * - Plain Node / Express does not define a standard `waitUntil` + * lifecycle. If your host exposes one, pass it through the optional + * adapter options so `createInstantHandler` can protect the main + * LLM → split → push pipeline. */ /** @@ -45,15 +49,23 @@ async function ensureWebCryptoPolyfill() { } /** - * @param {(request: Request) => Promise} fetchHandler + * @typedef {Object} NodeAdapterOptions + * @property {(work: Promise) => void} [waitUntil] + * @property {{ waitUntil?: (work: Promise) => void }} [runtime] + * @property {(req: import('http').IncomingMessage, res: import('http').ServerResponse) => { waitUntil?: (work: Promise) => void } | undefined | null} [getRuntime] + */ + +/** + * @param {(request: Request, runtime?: { waitUntil?: (work: Promise) => void }) => Promise} fetchHandler + * @param {NodeAdapterOptions} [options] * @returns {(req: import('http').IncomingMessage, res: import('http').ServerResponse) => Promise} */ -export function toNodeHandler(fetchHandler) { +export function toNodeHandler(fetchHandler, options = {}) { return async function nodeHandler(req, res) { try { await ensureWebCryptoPolyfill(); const fetchRequest = await nodeRequestToFetchRequest(req); - const fetchResponse = await fetchHandler(fetchRequest); + const fetchResponse = await fetchHandler(fetchRequest, resolveNodeRuntime(options, req, res)); await writeFetchResponseToNode(fetchResponse, res); } catch (err) { if (!res.headersSent) { @@ -70,6 +82,19 @@ export function toNodeHandler(fetchHandler) { }; } +function resolveNodeRuntime(options, req, res) { + if (options && typeof options.getRuntime === 'function') { + return options.getRuntime(req, res) || undefined; + } + if (options && options.runtime && typeof options.runtime === 'object') { + return options.runtime; + } + if (options && typeof options.waitUntil === 'function') { + return { waitUntil: options.waitUntil }; + } + return undefined; +} + async function nodeRequestToFetchRequest(req) { const host = req.headers.host || 'localhost'; const protocol = req.socket && req.socket.encrypted ? 'https' : 'http'; diff --git a/packages/rei-standard-amsg/instant/src/adapters/vercel.js b/packages/rei-standard-amsg/instant/src/adapters/vercel.js index db9c107..7306a93 100644 --- a/packages/rei-standard-amsg/instant/src/adapters/vercel.js +++ b/packages/rei-standard-amsg/instant/src/adapters/vercel.js @@ -27,15 +27,18 @@ import { toNodeHandler } from './node.js'; /** - * Edge runtime is already Fetch-API native, so this is a pass-through - * that keeps a uniform import path with the other adapters. + * Edge runtime is already Fetch-API native, so this is mostly a pass-through + * that keeps a uniform import path with the other adapters. The optional + * second argument is forwarded for runtimes that expose a request-scoped + * `waitUntil` on context-like objects. For Vercel's `@vercel/functions` + * helper, pass `waitUntil` directly to `createInstantHandler({ waitUntil })`. * - * @param {(request: Request) => Promise} fetchHandler - * @returns {(request: Request) => Promise} + * @param {(request: Request, runtime?: { waitUntil?: (work: Promise) => void }) => Promise} fetchHandler + * @returns {(request: Request, context?: { waitUntil?: (work: Promise) => void }) => Promise} */ export function toVercelEdgeHandler(fetchHandler) { - return async function vercelEdgeHandler(request) { - return fetchHandler(request); + return async function vercelEdgeHandler(request, context) { + return fetchHandler(request, context); }; } diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index b2bbd33..0f4caca 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -87,6 +87,11 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * @property {CorsConfig} [cors] - CORS configuration. Defaults to `{ allowOrigin: '*' }`. Every response (including the 204 preflight short-circuit) carries the matching `Access-Control-Allow-*` headers. * @property {Object} [webpush] - **Deprecated since 0.3.0.** Ignored. amsg-instant now implements RFC 8291 + RFC 8292 natively on Web Crypto. Tests should intercept the push HTTP request via `options.fetch` instead. * @property {typeof fetch} [fetch] - Optional fetch override (testing / custom proxy). Used for BOTH the LLM call and the outgoing Web Push POST. + * @property {(work: Promise) => void} [waitUntil] + * - Optional lifecycle extender for runtimes with a background + * completion hook. Prefer passing it per request when the + * platform provides a request-scoped context (for example + * Cloudflare Workers' `ctx.waitUntil`). * @property {(e: { type: string }) => void} [onEvent] * @property {(ctx: import('./session-context.js').SessionContext) => Promise | object} [onLLMOutput] * - **v0.7 hook.** When provided, the handler switches from @@ -141,9 +146,12 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * The handler is the same shape used by Cloudflare Workers, Deno Deploy, * Vercel Edge, and Bun. Wrap it with one of the platform adapters if you * are on Node/Express, Netlify Functions, or Vercel Serverless Functions. + * When used directly as a Cloudflare Workers module `fetch`, the extra + * `(env, ctx)` arguments are accepted so the main LLM → split → push + * pipeline can be protected by `ctx.waitUntil`. * * @param {InstantHandlerOptions} options - * @returns {(request: Request) => Promise} + * @returns {(request: Request, envOrRuntime?: unknown, runtime?: unknown) => Promise} */ export function createInstantHandler(options) { if (!options) throw new Error('[amsg-instant] options is required'); @@ -182,7 +190,7 @@ export function createInstantHandler(options) { const respond = (status, body) => jsonResponse(status, body, corsHeaders); - return async function handler(request) { + return async function handler(request, envOrRuntime, runtime) { onEvent({ type: 'request' }); // CORS preflight short-circuit. Browsers fire OPTIONS before any @@ -302,7 +310,7 @@ export function createInstantHandler(options) { } try { - const result = await processInstantMessage(payload, { + const work = processInstantMessage(payload, { vapid: options.vapid, fetch: options.fetch || globalThis.fetch, onEvent, @@ -314,6 +322,8 @@ export function createInstantHandler(options) { requestUrl: request.url, isResume: isContinue, }); + registerWaitUntil(work, resolveWaitUntil(envOrRuntime, runtime, options), onEvent); + const result = await work; return respond(200, { success: true, data: result }); } catch (err) { onEvent({ type: 'error', code: err?.code, message: err?.message }); @@ -334,6 +344,31 @@ export function createInstantHandler(options) { }; } +function resolveWaitUntil(envOrRuntime, runtime, options) { + if (runtime && typeof runtime.waitUntil === 'function') { + return { waitUntil: runtime.waitUntil, target: runtime }; + } + if (envOrRuntime && typeof envOrRuntime.waitUntil === 'function') { + return { waitUntil: envOrRuntime.waitUntil, target: envOrRuntime }; + } + if (options && typeof options.waitUntil === 'function') { + return { waitUntil: options.waitUntil, target: undefined }; + } + return null; +} + +function registerWaitUntil(work, lifecycle, onEvent) { + if (!lifecycle) return; + const backgroundWork = work.catch((err) => { + onEvent({ type: 'wait_until_rejected', code: err?.code, message: err?.message }); + }); + try { + lifecycle.waitUntil.call(lifecycle.target, backgroundWork); + } catch (err) { + onEvent({ type: 'wait_until_failed', cause: err }); + } +} + function resolveMultipartOptions(options) { const hasMultipart = options.multipart !== undefined; const raw = hasMultipart ? options.multipart : {}; diff --git a/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs b/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs new file mode 100644 index 0000000..1ada82c --- /dev/null +++ b/packages/rei-standard-amsg/instant/test/cloudflare-adapter.test.mjs @@ -0,0 +1,239 @@ +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; + +import { createInstantHandler } from '../src/index.js'; +import { createCloudflareWorker } from '../src/adapters/cloudflare.js'; +import { toNetlifyHandler } from '../src/adapters/netlify.js'; +import { toVercelEdgeHandler } from '../src/adapters/vercel.js'; +import { toNodeHandler } from '../src/adapters/node.js'; +import { + generateTestVapid, + generateTestSubscription, + createFetchRouter, + makeLlmResponse, +} from './helpers.mjs'; + +const LLM_URL = 'https://api.example.com/v1/chat/completions'; + +let vapid; +let subKit; + +before(async () => { + vapid = await generateTestVapid(); + subKit = await generateTestSubscription(); +}); + +function makeRequest(body) { + return new Request('https://worker.example.com/instant', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +function makePayload() { + return { + contactName: 'Rei', + completePrompt: 'say hi briefly', + apiUrl: LLM_URL, + apiKey: 'sk-test', + primaryModel: 'model-x', + pushSubscription: subKit.subscription, + }; +} + +function makeRouter(content = 'hi.') { + return createFetchRouter({ + pushEndpoint: subKit.subscription.endpoint, + llm: async () => makeLlmResponse(content), + }); +} + +describe('Cloudflare waitUntil lifecycle', () => { + it('createCloudflareWorker forwards ExecutionContext.waitUntil to the instant handler', async () => { + const router = makeRouter(); + const waitUntilPromises = []; + const ctx = { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }; + const worker = createCloudflareWorker((env) => ({ + vapid: { + email: env.VAPID_EMAIL, + publicKey: env.VAPID_PUBLIC_KEY, + privateKey: env.VAPID_PRIVATE_KEY, + }, + fetch: router.fetch, + })); + + const res = await worker.fetch(makeRequest(makePayload()), { + VAPID_EMAIL: vapid.email, + VAPID_PUBLIC_KEY: vapid.publicKey, + VAPID_PRIVATE_KEY: vapid.privateKey, + }, ctx); + + assert.equal(res.status, 200); + assert.equal(waitUntilPromises.length, 1); + const result = await waitUntilPromises[0]; + assert.equal(result.messagesSent, 1); + assert.equal(router.pushCalls.length, 1); + }); + + it('waitUntil background work resolves after the handler maps failures to HTTP errors', async () => { + const events = []; + const waitUntilPromises = []; + const worker = createCloudflareWorker((env) => ({ + vapid: { + email: env.VAPID_EMAIL, + publicKey: env.VAPID_PUBLIC_KEY, + privateKey: env.VAPID_PRIVATE_KEY, + }, + fetch: async () => new Response('upstream down', { status: 500 }), + onEvent(event) { + events.push(event); + }, + })); + + const res = await worker.fetch(makeRequest(makePayload()), { + VAPID_EMAIL: vapid.email, + VAPID_PUBLIC_KEY: vapid.publicKey, + VAPID_PRIVATE_KEY: vapid.privateKey, + }, { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + + assert.equal(res.status, 502); + assert.equal(waitUntilPromises.length, 1); + await assert.doesNotReject(waitUntilPromises[0]); + assert.equal(await waitUntilPromises[0], undefined); + assert.equal(events.some((event) => event.type === 'wait_until_rejected'), true); + }); + + it('direct Cloudflare module usage of createInstantHandler can read ctx from the third fetch arg', async () => { + const router = makeRouter(); + const waitUntilPromises = []; + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + + const res = await handler(makeRequest(makePayload()), {}, { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + + assert.equal(res.status, 200); + assert.equal(waitUntilPromises.length, 1); + assert.equal((await waitUntilPromises[0]).messagesSent, 1); + }); +}); + +describe('adapter waitUntil lifecycle', () => { + it('createInstantHandler can use options.waitUntil when no adapter context exists', async () => { + const router = makeRouter(); + const waitUntilPromises = []; + const handler = createInstantHandler({ + vapid, + fetch: router.fetch, + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + + const res = await handler(makeRequest(makePayload())); + + assert.equal(res.status, 200); + assert.equal(waitUntilPromises.length, 1); + assert.equal((await waitUntilPromises[0]).messagesSent, 1); + }); + + it('toNetlifyHandler forwards context.waitUntil', async () => { + const router = makeRouter(); + const waitUntilPromises = []; + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const netlifyHandler = toNetlifyHandler(handler); + + const res = await netlifyHandler(makeRequest(makePayload()), { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + + assert.equal(res.status, 200); + assert.equal(waitUntilPromises.length, 1); + assert.equal((await waitUntilPromises[0]).messagesSent, 1); + }); + + it('toVercelEdgeHandler forwards context.waitUntil when a runtime provides it', async () => { + const router = makeRouter(); + const waitUntilPromises = []; + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const vercelHandler = toVercelEdgeHandler(handler); + + const res = await vercelHandler(makeRequest(makePayload()), { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + + assert.equal(res.status, 200); + assert.equal(waitUntilPromises.length, 1); + assert.equal((await waitUntilPromises[0]).messagesSent, 1); + }); + + it('toNodeHandler can inject a host waitUntil through adapter options', async () => { + const router = makeRouter(); + const waitUntilPromises = []; + const handler = createInstantHandler({ vapid, fetch: router.fetch }); + const nodeHandler = toNodeHandler(handler, { + waitUntil(work) { + waitUntilPromises.push(work); + }, + }); + const { req, res, bodyText } = makeNodeRequestResponse(makePayload()); + + await nodeHandler(req, res); + + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(bodyText()).success, true); + assert.equal(waitUntilPromises.length, 1); + assert.equal((await waitUntilPromises[0]).messagesSent, 1); + }); +}); + +function makeNodeRequestResponse(body) { + const rawBody = JSON.stringify(body); + const req = Readable.from([Buffer.from(rawBody)]); + req.method = 'POST'; + req.url = '/instant'; + req.headers = { + host: 'localhost', + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(rawBody)), + }; + req.socket = {}; + + const chunks = []; + const res = { + statusCode: 200, + headersSent: false, + headers: {}, + setHeader(name, value) { + this.headers[String(name).toLowerCase()] = String(value); + }, + end(chunk) { + if (chunk) chunks.push(Buffer.from(chunk)); + this.headersSent = true; + }, + }; + + return { + req, + res, + bodyText() { + return Buffer.concat(chunks).toString('utf8'); + }, + }; +} From ae3cf6973f1be85062ea8f73329e39255af564d8 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 26 May 2026 00:45:11 +0800 Subject: [PATCH 31/33] docs: finalize v2.1.0 and 0.8.0 docs --- README.md | 32 +++-- bump.mjs | 10 +- examples/README.md | 2 +- package-lock.json | 18 +-- .../rei-standard-amsg/client/CHANGELOG.md | 4 +- packages/rei-standard-amsg/client/README.md | 4 +- .../rei-standard-amsg/client/package.json | 4 +- .../rei-standard-amsg/instant/CHANGELOG.md | 5 +- packages/rei-standard-amsg/instant/README.md | 61 +++++---- .../rei-standard-amsg/instant/package.json | 4 +- .../rei-standard-amsg/instant/pnpm-lock.yaml | 10 +- .../rei-standard-amsg/instant/src/index.js | 51 +++----- .../instant/src/message-processor.js | 20 +-- .../instant/src/validation.js | 4 +- .../instant/test/agentic-loop.test.mjs | 14 +- .../instant/test/handler.test.mjs | 8 +- .../instant/test/pushpayloads-array.test.mjs | 6 +- .../instant/test/reasoning-push.test.mjs | 8 +- .../rei-standard-amsg/server/CHANGELOG.md | 4 +- packages/rei-standard-amsg/server/README.md | 4 +- .../rei-standard-amsg/server/package.json | 4 +- .../rei-standard-amsg/shared/CHANGELOG.md | 2 +- .../rei-standard-amsg/shared/package.json | 2 +- packages/rei-standard-amsg/sw/CHANGELOG.md | 2 +- packages/rei-standard-amsg/sw/README.md | 8 +- packages/rei-standard-amsg/sw/package.json | 4 +- standards/active-messaging-api.md | 62 ++++++--- standards/service-worker-specification.md | 122 +++++++++++++----- 28 files changed, 279 insertions(+), 200 deletions(-) diff --git a/README.md b/README.md index aa99b63..b199692 100644 --- a/README.md +++ b/README.md @@ -6,36 +6,34 @@ | 包 | 版本 | 用途 | |---|---|---| -| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | `0.1.0-next.0` | 三轴推送契约(`AmsgPush` 判别联合 + builders + 类型守卫) | -| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.8.0-next.0` | 一次性即时推送(无 DB、无 cron、无租户) | -| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.4.0-next.0` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | -| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.3.0-next.0` | 浏览器 SDK:加密、请求封装、Push 订阅 | -| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.1.0-next.0` | Service Worker:推送展示、离线队列 | +| [`@rei-standard/amsg-shared`](./packages/rei-standard-amsg/shared/README.md) | `0.1.0` | 三轴推送契约(`AmsgPush` 判别联合 + builders + 类型守卫) | +| [`@rei-standard/amsg-instant`](./packages/rei-standard-amsg/instant/README.md) | `0.8.0` | 一次性即时推送(无 DB、无 cron、无租户) | +| [`@rei-standard/amsg-server`](./packages/rei-standard-amsg/server/README.md) | `2.4.0` | 定时 / 周期消息,多租户 Blob 配置 + token 鉴权 | +| [`@rei-standard/amsg-client`](./packages/rei-standard-amsg/client/README.md) | `2.3.0` | 浏览器 SDK:加密、请求封装、Push 订阅 | +| [`@rei-standard/amsg-sw`](./packages/rei-standard-amsg/sw/README.md) | `2.1.0` | Service Worker:推送展示、离线队列 | `amsg-shared` 是依赖图最底层:其他四个包都依赖它,反过来不行;它本身零运行时依赖。 **怎么挑服务端包**:只发"按钮点了就立刻推一条" → `amsg-instant`;要定时或周期任务 → `amsg-server`;两种都要就都装,共用同一套 VAPID 与 masterKey。 -### 协调发布说明:next pre-release(shared 0.1.0-next.0 / instant 0.8.0-next.0 / sw 2.1.0-next.0 / client 2.3.0-next.0 / server 2.4.0-next.0) +### 协调发布说明:稳定版发布(shared 0.1.0 / instant 0.8.0 / sw 2.1.0 / client 2.3.0 / server 2.4.0) -本轮是一次跨包协调的 minor 升级,统一 push wire shape 到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器)。所有 amsg 子包同时上调一个 minor,并以 `-next.0` 形式预发布。仓库的 `scripts/publish-workspaces.mjs` 自动识别 prerelease 版本号并把发布打到 `next` dist-tag(不进 `latest`)。 +本轮是一次跨包协调的 minor 升级,统一 push wire shape 到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器)。所有 amsg 子包同时上调一个 minor 并正式发布。 -- `@rei-standard/amsg-shared` 新增 → `0.1.0-next.0` -- `@rei-standard/amsg-instant`:`0.7.0` → `0.8.0-next.0` -- `@rei-standard/amsg-server`:`2.3.2` → `2.4.0-next.0` -- `@rei-standard/amsg-sw`:`2.0.1` → `2.1.0-next.0` -- `@rei-standard/amsg-client`:`2.2.3` → `2.3.0-next.0` +- `@rei-standard/amsg-shared` 新增 → `0.1.0` +- `@rei-standard/amsg-instant`:`0.7.0` → `0.8.0` +- `@rei-standard/amsg-server`:`2.3.2` → `2.4.0` +- `@rei-standard/amsg-sw`:`2.0.1` → `2.1.0` +- `@rei-standard/amsg-client`:`2.2.3` → `2.3.0` -包间依赖一律使用**精确版本**(不带 `^`),所有 `dependencies` 字段都钉死在对应的 `*-next.0` 版本,避免 npm 在生态系统里解析出混版本图。同时本轮移除了旧的 `{ type: 'error', code: '...' }` 错误信封——错误推送统一走 `ErrorPush`(`messageKind: 'error'`)。 +包间依赖一律使用**精确版本**(不带 `^`),所有 `dependencies` 字段都钉死在对应的版本,避免 npm 在生态系统里解析出混版本图。同时本轮移除了旧的 `{ type: 'error', code: '...' }` 错误信封——错误推送统一走 `ErrorPush`(`messageKind: 'error'`)。 -**安装预发布版(`next` dist-tag)**: +**安装最新版(`latest` dist-tag)**: ```bash -npm install @rei-standard/amsg-shared@next @rei-standard/amsg-instant@next @rei-standard/amsg-server@next @rei-standard/amsg-sw@next @rei-standard/amsg-client@next +npm install @rei-standard/amsg-shared @rei-standard/amsg-instant @rei-standard/amsg-server @rei-standard/amsg-sw @rei-standard/amsg-client ``` -`next` 期间欢迎下游集成方反馈契约问题;契约稳定后会发对应的 `1.0` / `2.x` 正式 minor(去掉 `-next.N` 后缀,走默认 `latest` dist-tag)。 - ## 三轴推送语义(Three-axis push schema) 每一条推送都由三个**正交**的维度描述。把"用什么方式发出去"(dispatch)、"业务命名空间"(business)、"载荷里装的是什么"(content)拆开,让一个 axis 加值的时候不需要动另外两个 axis。 diff --git a/bump.mjs b/bump.mjs index 3d98f01..81f9514 100644 --- a/bump.mjs +++ b/bump.mjs @@ -11,8 +11,8 @@ function updatePkg(pkgPath, version, sharedDep) { fs.writeFileSync(file, JSON.stringify(json, null, 2) + '\n'); } -updatePkg('packages/rei-standard-amsg/shared/package.json', '0.1.0-next.4', null); -updatePkg('packages/rei-standard-amsg/sw/package.json', '2.1.0-next.4', '0.1.0-next.4'); -updatePkg('packages/rei-standard-amsg/instant/package.json', '0.8.0-next.8', '0.1.0-next.4'); -updatePkg('packages/rei-standard-amsg/client/package.json', '2.3.0-next.2', '0.1.0-next.4'); -updatePkg('packages/rei-standard-amsg/server/package.json', '2.4.0-next.2', '0.1.0-next.4'); +updatePkg('packages/rei-standard-amsg/shared/package.json', '0.1.0', null); +updatePkg('packages/rei-standard-amsg/sw/package.json', '2.1.0', '0.1.0'); +updatePkg('packages/rei-standard-amsg/instant/package.json', '0.8.0', '0.1.0'); +updatePkg('packages/rei-standard-amsg/client/package.json', '2.3.0', '0.1.0'); +updatePkg('packages/rei-standard-amsg/server/package.json', '2.4.0', '0.1.0'); diff --git a/examples/README.md b/examples/README.md index fa7d84a..4f25d16 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,7 +9,7 @@ > | 增量 | SDK 起始版本 | 示例缺什么 | > |---|---|---| > | OpenAI 格式 `messages` 数组(system / 多轮 / tool role)+ `temperature` 透传 | server 2.2.0 · instant 0.5.0 · client 2.2.1 | `lib/message-processor.js` 的 `buildAiRequestBody` 把 prompt 硬包成单条 user 消息 | -> | `avatarUrl` 软清空(不合法值 `console.warn` + 置空,不再 400 整个任务) | server 2.3.3 / 2.4.0-next.1 · instant 0.7.1 / 0.8.0-next.1 · client 2.2.4 / 2.3.0-next.1 | 只检 `new URL(...)` 能 parse;`data:` base64 头像会进库再触发下游 413 | +> | `avatarUrl` 软清空(不合法值 `console.warn` + 置空,不再 400 整个任务) | server 2.3.3 / 2.4.0 · instant 0.7.1 / 0.8.0 · client 2.2.4 / 2.3.0 | 只检 `new URL(...)` 能 parse;`data:` base64 头像会进库再触发下游 413 | > > 新接入请直接用 SDK 包(`@rei-standard/amsg-server` / `amsg-instant` / `amsg-client`),行为已按规范对齐到字节级。这份示例的文档与代码后续会同步更新。 diff --git a/package-lock.json b/package-lock.json index 2bd7328..ae4251d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1850,10 +1850,10 @@ }, "packages/rei-standard-amsg/client": { "name": "@rei-standard/amsg-client", - "version": "2.3.0-next.2", + "version": "2.3.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4" + "@rei-standard/amsg-shared": "0.1.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -1865,10 +1865,10 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.8", + "version": "0.8.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4" + "@rei-standard/amsg-shared": "0.1.0" }, "devDependencies": { "tsup": "^8.0.0", @@ -1880,11 +1880,11 @@ }, "packages/rei-standard-amsg/server": { "name": "@rei-standard/amsg-server", - "version": "2.4.0-next.2", + "version": "2.4.0", "license": "MIT", "dependencies": { "@netlify/blobs": "^8.1.0", - "@rei-standard/amsg-shared": "0.1.0-next.4", + "@rei-standard/amsg-shared": "0.1.0", "web-push": "^3.6.7" }, "devDependencies": { @@ -1911,7 +1911,7 @@ }, "packages/rei-standard-amsg/shared": { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.4", + "version": "0.1.0", "license": "MIT", "devDependencies": { "tsup": "^8.0.0", @@ -1923,10 +1923,10 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.4", + "version": "2.1.0", "license": "MIT", "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4" + "@rei-standard/amsg-shared": "0.1.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/client/CHANGELOG.md b/packages/rei-standard-amsg/client/CHANGELOG.md index b6d321a..aad3feb 100644 --- a/packages/rei-standard-amsg/client/CHANGELOG.md +++ b/packages/rei-standard-amsg/client/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog — @rei-standard/amsg-client -## 2.3.0-next.2 — Dependency bump (pre-release) +## 2.3.0 — Dependency bump -- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至 `0.1.0-next.4`。 +- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至稳定版 `0.1.0`。 ## 2.3.0-next.1 — avatarUrl 本地软清空 (pre-release) diff --git a/packages/rei-standard-amsg/client/README.md b/packages/rei-standard-amsg/client/README.md index 33a1056..0ac83b3 100644 --- a/packages/rei-standard-amsg/client/README.md +++ b/packages/rei-standard-amsg/client/README.md @@ -180,7 +180,7 @@ await client.sendInstant({ - 传**正则 source**,不要带 `/.../` 也不要尾 flag。`'/foo/i'` 会被当字面量斜杠 + 字面量 `i`,不是大小写不敏感的 `foo`。大小写不敏感请用 `[Aa]` 字符类替代。 - 想让分隔符回贴到前一段(默认行为),把分隔符包进 `(...)` 捕获组。库**不会自动包**——传 `'\\n+'` 而不是 `'(\\n+)'` 会得到首尾相连、分隔符丢失的奇怪结果。 -### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0-next.1+) +### 本地软清空:`avatarUrl` 与 payload 体积(2.2.4+ / 2.3.0+) `scheduleMessage` / `sendInstant` / `updateMessage` 在发请求**之前**会在本地做两项保护: @@ -205,7 +205,7 @@ try { } ``` -服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0-next.1+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0-next.1+)有同样的软清空二道防线,client 这一道主要省一次远端往返。 +服务端(`@rei-standard/amsg-instant` 0.7.1+ / 0.8.0+,`@rei-standard/amsg-server` 2.3.3+ / 2.4.0+)有同样的软清空二道防线,client 这一道主要省一次远端往返。 ## 导出 API(Exports) diff --git a/packages/rei-standard-amsg/client/package.json b/packages/rei-standard-amsg/client/package.json index 2f34d64..a5a272f 100644 --- a/packages/rei-standard-amsg/client/package.json +++ b/packages/rei-standard-amsg/client/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-client", - "version": "2.3.0-next.2", + "version": "2.3.0", "description": "ReiStandard Active Messaging browser client SDK — also re-exports shared push types, builders, and guards from @rei-standard/amsg-shared", "repository": { "type": "git", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4" + "@rei-standard/amsg-shared": "0.1.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 15fff98..d4ed818 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog — @rei-standard/amsg-instant -## 0.8.0-next.8 — waitUntil lifecycle support (pre-release) +## 0.8.0 — waitUntil lifecycle support +- 稳定版发布:`0.8.0-next.*` 能力毕业到 latest,依赖收敛到 `@rei-standard/amsg-shared@0.1.0`。 - `waitUntil` 注册的是后台生命周期保护 promise;主流程失败仍由 handler 转成原有 HTTP 错误响应,同时通过 `wait_until_rejected` 事件记录,不额外制造 rejected background promise。 -- Cloudflare Workers:`createCloudflareWorker.fetch` 现在接收第三个 `ExecutionContext` 参数,并把主回复链路(LLM 生成、切段、逐条 Web Push)交给 `ctx.waitUntil` 保护。直接把 `createInstantHandler(...)` 挂成 Worker module `fetch` 时,也会识别 Cloudflare 传入的 `(request, env, ctx)`。 +- Cloudflare Workers:`createCloudflareWorker.fetch` 现在接收第三个 `ExecutionContext` 参数,并把主回复链路(LLM 生成、构造/切段 push payloads、逐条 Web Push)交给 `ctx.waitUntil` 保护。直接把 `createInstantHandler(...)` 挂成 Worker module `fetch` 时,也会识别 Cloudflare 传入的 `(request, env, ctx)`。 - 其他运行时:`createInstantHandler` 新增通用 `waitUntil` 生命周期入口;Netlify / Vercel Edge adapters 会透传第二个 context 参数;Node adapter 新增可选 `toNodeHandler(fetchHandler, { waitUntil | runtime | getRuntime })`,方便宿主有生命周期钩子时统一保护主回复链路。 ## 0.8.0-next.7 — Dependency bump (pre-release) diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index 7611346..b1f2c76 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -1,6 +1,6 @@ # @rei-standard/amsg-instant -**零运行时依赖**的无状态明文一次性即时推送处理器:整个生命周期 = 一次 HTTP 函数调用(解析 → 调 LLM → 分句 → 发 Web Push → 返回 200)。无数据库、无 cron、无租户初始化。从 0.3.0 起 RFC 8291 (`aes128gcm`) payload 加密和 RFC 8292 VAPID JWT 由内置实现完成,不再需要 `web-push` / Node `crypto`。 +**零运行时依赖**的无状态明文一次性即时推送处理器:整个生命周期 = 一次 HTTP 函数调用(解析 → 调 LLM → 构造/切分 push payloads → 发 Web Push → 返回 200)。无数据库、无 cron、无租户初始化。从 0.3.0 起 RFC 8291 (`aes128gcm`) payload 加密和 RFC 8292 VAPID JWT 由内置实现完成,不再需要 `web-push` / Node `crypto`。 定位是**单租户自部署**场景下的极简 instant 推送:前端、Worker、LLM key 都在你自己手里,链路只剩浏览器 → Worker 的 HTTPS。应用层加密在该场景下没有实际收益(HTTPS 已加密传输;apiKey 由前端塞进 payload 必然要让 Worker 见到;攻击者拿 Worker URL 也榨不出 apiKey、推不动别人订阅),所以从 0.2.0 起协议改为**纯明文**。 @@ -38,10 +38,10 @@ npm install @rei-standard/amsg-instant | `onEvent` | function | ❌ | 事件钩子:`request` / `llm_done` / `push_sent` / `error`(明文模式下 `request` 事件不再带 userId —— 如果需要按用户分流日志,从 `payload.contactName` 或 `payload.metadata` 自取) | | `onLLMOutput` | function | ❌ | **0.7.0+**:每轮 LLM 输出后的决策钩子。配了它就进 agentic loop 模式;不配则走 v0.6 老路径(字节级兼容)。见 [Agentic Loop](#agentic-loop070) | | `blobStore` | object | ❌ | **0.7.0+**:可选 blob 后端。push payload UTF-8 字节超过 `maxInlineBytes`(默认 2600)时自动把 body 写进 store、改推 200 B envelope。见 [BlobStore](#blobstore070) | -| `multipart` | object | ❌ | **next+**:通用 multipart transport。超出 inline、且没配 BlobStore 时,任意 JSON-safe payload 都可拆成 `_multipart` 分片。默认 `enabled:true`、`maxChunkBytes:1800`、`ttlMs:60000`、`maxChunks:128`、`maxTotalBytes:256000`。见 [Generic multipart transport](#generic-multipart-transportnext)。 | +| `multipart` | object | ❌ | **0.8.0+**:通用 multipart transport。超出 inline、且没配 BlobStore 时,任意 JSON-safe payload 都可拆成 `_multipart` 分片。默认 `enabled:true`、`maxChunkBytes:1800`、`ttlMs:60000`、`maxChunks:128`、`maxTotalBytes:256000`。见 [Generic multipart transport](#generic-multipart-transport080)。 | | `maxLoopIterations` | number | ❌ | **0.7.0+**:单次 worker 调用内 `decision:'continue'` 的硬上限,默认 10。仅防本进程内 hook 反复 continue 失控;跨请求的 `/continue` 洪水攻击由上游 auth/rate-limit 处理 | | `autoEmitReasoning` | boolean | ❌ | **0.8.0+**:默认 `true`。`true` 时框架在调 hook 前自动 emit `ReasoningPush`(如果 LLM 响应带非空 `reasoning_content`)。`false` 把 reasoning emit 完全交给 hook 自己负责(hook 可读 `ctx.llmResponse.choices[0].message.reasoning_content` 并用 `buildReasoningPush` + 自己 dispatch)。legacy 路径忽略此项始终自动 emit。 | -| `reasoningChunkBytes` | number \| null | ❌ | **Deprecated in next**:旧 reasoning 专用字节切配置。保留为 `multipart.maxChunkBytes` 的兼容别名;`null` 仅在未显式配置 `multipart` 时禁用 generic multipart。不会再产生 `chunkIndex` / `totalChunks` reasoning wire fields。 | +| `reasoningChunkBytes` | number \| null | ❌ | **Deprecated in 0.8.0**:旧 reasoning 专用字节切配置。保留为 `multipart.maxChunkBytes` 的兼容别名;`null` 仅在未显式配置 `multipart` 时禁用 generic multipart。不会再产生 `chunkIndex` / `totalChunks` reasoning wire fields。 | ### 鉴权策略 @@ -81,7 +81,7 @@ createInstantHandler({ 返回标准 Web Fetch API handler。直接挂到 Cloudflare Workers / Deno Deploy / Vercel Edge / Bun,或用下方四个 adapter 接到 Node / Netlify。 如果运行时提供 `waitUntil`,可以通过请求 context 或 `options.waitUntil` 交给 handler, -主回复链路(LLM 生成 → 切段 → 逐条 Web Push)会被注册进去。 +主回复链路(LLM 生成 → 构造/切段 push payloads → 逐条 Web Push)会被注册进去。 ```js import { createInstantHandler } from '@rei-standard/amsg-instant'; @@ -118,7 +118,7 @@ Content-Type: application/json ```ts { contactName: string; - avatarUrl?: string | null; // 0.7.1+ / 0.8.0-next.1+:不合法值(data: URI / 长度 > 2KB / 非字符串)软清空 + console.warn,整次推送不再 fail + avatarUrl?: string | null; // 0.7.1+ / 0.8.0+:不合法值(data: URI / 长度 > 2KB / 非字符串)软清空 + console.warn,整次推送不再 fail // === 提示词,二选一恰好一个(0.5.0+)=== completePrompt?: string; // 简单推送场景:单 user 消息 @@ -230,12 +230,12 @@ curl -X POST https://instant.example.com/instant \ | `LLM_CALL_FAILED` | 502 | 0.1 | 上游 LLM 请求失败 | | `PUSH_SEND_FAILED` | 502 | 0.1 | Web Push 派送失败 | | `COMPLETE_PROMPT_NOT_SUPPORTED_ON_HOOK_PATH` | 400 | 0.7 | 配了 `onLLMOutput` 之后 `/instant` 或 `/continue` 还传 `completePrompt`;hook 路径只接受 `messages` 数组 | -| `HOOK_THREW` | 500 | 0.7 | `onLLMOutput` 抛错或返了非法 decision(`null` / 不识别的 `decision` 值 / `pushPayloads` 不是数组或为空 / 单个 push 不可 JSON-serialize)。同时会推一条诊断 push(payload `{type:'error', code:'HOOK_THREW',...}`) | +| `HOOK_THREW` | 500 | 0.7 | `onLLMOutput` 抛错或返了非法 decision(`null` / 不识别的 `decision` 值 / `pushPayloads` 不是数组或为空 / 单个 push 不可 JSON-serialize)。同时会推一条诊断 `ErrorPush`(`{ messageKind:'error', code:'HOOK_THREW', message, iteration? }`) | | `PAYLOAD_TOO_LARGE` | 500 | 0.7 | hook 返的 `pushPayloads` 中某个 push UTF-8 字节超 `maxInlineBytes`,且没有 BlobStore、generic multipart 也被禁用或超过 multipart 上限。配上 BlobStore 会优先走 envelope;没配 BlobStore 时默认走 generic multipart | | `CONTINUE_NOT_AVAILABLE` | 400 | 0.7 | 往没配 `onLLMOutput` 的 handler POST `/continue`。`/continue` 是 agentic loop 的续跑端点,没钩子就没东西可续,直接拒掉避免误报成 `HOOK_THREW` | | `INTERNAL_ERROR` | 500 | 0.1 | 其他未分类内部错误 | -**`LOOP_EXCEEDED` 不是错误码** —— hook 反复返 `decision:'continue'` 超 `maxLoopIterations` 时,worker 返 HTTP **200** + body `{ success: true, data: { status: 'loop_exceeded', sessionId, iteration } }`,并向 SW 推一条 `{type:'error', code:'LOOP_EXCEEDED',...}` 诊断 envelope。HTTP 层是正常完成,不会让客户端误重试。 +**`LOOP_EXCEEDED` 不是错误码** —— hook 反复返 `decision:'continue'` 超 `maxLoopIterations` 时,worker 返 HTTP **200** + body `{ success: true, data: { status: 'loop_exceeded', sessionId, iteration } }`,并向 SW 推一条 `{ messageKind:'error', code:'LOOP_EXCEEDED', message, iteration }` 诊断 `ErrorPush`。HTTP 层是正常完成,不会让客户端误重试。 **`/blob/:key` 端点的 error envelope 不同** —— 它走 plain `{ error: 'invalid_key' | 'blob_not_found_or_expired' | 'blob_store_not_configured' | 'blob_read_failed' }`,因为这条路径是给 SW 直 fetch 用的、跟主 SDK 的 wrap envelope 不在一条契约上。 @@ -270,7 +270,7 @@ v0.7 在 v0.6 之上**追加**了一个 hook 路径:配置 `onLLMOutput` 后 **两条路径互不干扰**: - **不配 `onLLMOutput`** → 原 v0.6 单次 LLM + 分句 + 串行 push(默认 1500 ms 间隔,13 字段 payload)。字节级与 v0.6 一致。 -- **配了 `onLLMOutput`** → 进 agentic loop,每轮 LLM 输出后调 hook 做 decision;hook 返什么就执行什么。切分完全由 hook 自己负责(`pushPayloads` 数组),见 [切分由 caller 负责](#切分由-caller-负责080-next4-起)。 +- **配了 `onLLMOutput`** → 进 agentic loop,每轮 LLM 输出后调 hook 做 decision;hook 返什么就执行什么。切分完全由 hook 自己负责(`pushPayloads` 数组),见 [切分由 caller 负责](#切分由-caller-负责080-起)。 ### hook 签名 @@ -313,24 +313,29 @@ lib 给每个 push 自动补这 3 个机械字段(hook 自己设 `messageId` 剩下所有字段(`messageKind` / `notification` / `metadata` / kind 特定字段(e.g. tool_request 的 toolCalls / reasoning 的 reasoningContent / error 的 code) / 等)都是 per-push,caller 完全控制。每个 push 必须 **JSON-safe**(无循环引用 / BigInt / function 字段),否则被当作 hook 契约违反走 `HookError` / `HOOK_THREW` 路径。 -### 切分由 caller 负责(0.8.0-next.4 起) +### 切分由 caller 负责(0.8.0 起) -next.4 起 lib 不再做任何拆分。hook 返 `pushPayloads: PushPayload[]`,里面装的就是 lib 会原样依次发的 N 条 push。常见 caller 会自己实现: +0.8.0 起 lib 不再接收 `splitPattern` 这类公共旋钮,也没有新的 handler 级 `splitFn` 参数。新方法是:hook 里调用你自己的 split 函数,然后返回 `pushPayloads: PushPayload[]`。数组里装的就是 lib 会原样依次发的 N 条 push。常见 caller 会自己实现: - 一些 caller 的分句逻辑混合多种启发式:换行、自定义 sentinel、跨语言断句、tag-level 边界等,用单个正则表达力不够 - 按 inline 业务标签(自定义占位符 / markup 片段)独立成段 - 切完空段 `filter`、按业务规则前后 `merge` / `split` 二阶段 - per-chunk 显示文本和载荷文本可以不一样:`notification.body` 是 OS banner(受长度 / 字符集限制、可能需要纯文本预览),`message` 字段给客户端 app 用完整原文做后处理 -如果想要 0.7 / next.2 / next.3 的「默认 `/([。!?!?]+)/` 句切」行为,自己写: +如果想要 0.7 时代「默认 `/([。!?!?]+)/` 句切」行为,自己写一个本地函数: ```js -const segments = text.split(/([。!?!?]+)/g) - .reduce((acc, part, i, arr) => { - if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); - return acc; - }, []) - .filter((s) => s.length > 0); +function splitForPush(text) { + const segments = text.split(/([。!?!?]+)/) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter((s) => s.length > 0); + return segments.length > 0 ? segments : [text]; +} + +const segments = splitForPush(ctx.llmOutputText); return { decision: 'finish', @@ -343,7 +348,7 @@ return { }; ``` -请求 body 上的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 在 next.4 里直接 400;push 上带 `splitPattern` 抛 `HookError`。pre-release 强迫一次性改干净。 +请求 body 上的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 在 0.8.0 里直接 400;push 上带 `splitPattern` 抛 `HookError`。迁移时把旧正则逻辑搬进 hook 内的自定义 split 函数,再返回一组 `pushPayloads`。 #### 例 1:单 push @@ -437,7 +442,7 @@ onLLMOutput(ctx) { 防的是**单次 worker 调用内 hook 反复返回 `{decision:'continue'}`** 的失控循环。默认 10,超限后 worker 直接: 1. emit `loop_exceeded` 事件 -2. 用 `sendPushWithMaybeBlob` 推一条 `{ type:'error', code:'LOOP_EXCEEDED', sessionId, iteration }` envelope +2. 用 `sendPushWithMaybeBlob` 推一条 `{ messageKind:'error', code:'LOOP_EXCEEDED', message, sessionId, iteration }` 诊断 `ErrorPush` 3. HTTP **200** + body `{ status: 'loop_exceeded', sessionId, iteration }` —— 注意**不是 5xx**,worker 已经完成了"推一条诊断给 SW"的合约,不该让客户端把它当可重试失败 **保护范围**:仅限单次 worker 调用内的 in-loop counter。跨请求的 `/continue` 洪水攻击(恶意客户端反复打 `/continue` 一直传 `iteration:0`)由部署方的 auth / rate-limit 负责,**不是这个守卫的活**。 @@ -514,13 +519,13 @@ POST body(结构与 `/instant` 入口相同 + `sessionId` + `iteration`): - **软失败**(链路可继续 / 自愈):`blob_put_failed` / `blob_orphaned` / `diagnostic_push_failed` / `payload_too_large` - **硬错误**(worker 中止链路):`hook_threw` / `loop_exceeded` / `llm_call_failed` -**push payload(SW 收到的 wire format)仍用 `{type:'error', code:'...'}`** —— SW 路由按"先看大类、再看 code"更顺手,跟事件分类是独立两层。 +**push payload(SW 收到的 wire format)统一走 `ErrorPush`** —— 0.8.0 起错误诊断 push 使用 `{ messageKind:'error', code, message, iteration? }`。旧的 `{type:'error', code:'...'}` envelope 已移除,不要在新 SW 代码里按 `type === 'error'` 分支。 --- -## Generic multipart transport(next) +## Generic multipart transport(0.8.0+) -> **next BREAKING**:旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除。`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,超限时都走同一套 generic `_multipart` transport。应用层不应该再监听或拼接 reasoning 半片。 +> **0.8.0 BREAKING**:旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format 已移除。`reasoning`、`tool_request`、`content`、`error`、`emotion_update` 或任何自定义 `messageKind`,只要是 JSON-safe payload,超限时都走同一套 generic `_multipart` transport。应用层不应该再监听或拼接 reasoning 半片。 发送优先级很简单: @@ -596,7 +601,7 @@ Web Push 上实际发出的每片是: ### 迁移说明 - 应用级 SW 可以删除自定义 reasoning 拼接逻辑。`@rei-standard/amsg-sw` 会透明重组,client 只收到完整 `messageKind: 'reasoning'` payload。 -- 不要再依赖 `chunkIndex` / `totalChunks` 判断 reasoning 是否完整;next 版本不会再发这些字段。 +- 不要再依赖 `chunkIndex` / `totalChunks` 判断 reasoning 是否完整;0.8.0+ 版本不会再发这些字段。 - `_multipart` 是保留的 transport kind,不触发业务事件、不弹通知。 - `content` multipart 收齐后照常 `postMessage` + `showNotification`;`tool_request` / `reasoning` / `error` 仍默认只 `postMessage` 不通知。 - 发送端会 emit `multipart_built` / `multipart_sent` 事件用于可观测性。旧 `reasoning_chunked` 事件不再表示 wire 行为。 @@ -628,7 +633,7 @@ agentic loop 模式下 payload 大小分布(经验值): | tool-request push(带本轮 LLM 原文) | 0.8–1.8 KB | 2.5 KB | 3.5 KB | | tool-request + reasoning | 1.5–3 KB | 4 KB(临界) | 5–6 KB(超) | -→ **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。next 阶段引入 [generic multipart transport](#generic-multipart-transportnext) 后,没有 BlobStore 时也能透明拆分任意 JSON-safe payload;`BlobStore` 仍是更可靠方案,且优先级高于 multipart:超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, messageKind?, type? }`,SW / client 再按 envelope 约定读取真 body。 +→ **90 % 场景直传安全**,但开 reasoning / 长输出的 p90-p99 会超。0.8.0 阶段引入 [generic multipart transport](#generic-multipart-transport080) 后,没有 BlobStore 时也能透明拆分任意 JSON-safe payload;`BlobStore` 仍是更可靠方案,且优先级高于 multipart:超限 payload 写到外部存储,push 只推 ~200 B envelope `{ _blob:true, key, url, messageKind?, type? }`,SW / client 再按 envelope 约定读取真 body。 ### 何时启用 / 何时跳过 @@ -791,9 +796,9 @@ Redis / Upstash 等带原生 TTL 的后端不用挂;KV 同理。 ## Migrating from v0.6 -**绝大多数 v0.6 用户什么都不用改**。包升到 0.7.0 后: +**绝大多数 v0.6 用户什么都不用改**。包升到 0.8.0 后: -- 不配 `onLLMOutput` → 跑 legacy 路径,字节级与 v0.6 一致(同 13 字段 payload、同 1500 ms 间隔、同 `onEvent` 事件)。legacy 路径内部仍按 `/([。!?!?]+)/` 默认句切,**只是**请求 body 上的 `splitPattern` 公共旋钮在 0.8.0-next.4 里被移除了(详见 0.8 迁移指南)。 +- 不配 `onLLMOutput` → 跑 legacy 路径,字节级与 v0.6 一致(同 13 字段 payload、同 1500 ms 间隔、同 `onEvent` 事件)。legacy 路径内部仍按 `/([。!?!?]+)/` 默认句切,**只是**请求 body 上的 `splitPattern` 公共旋钮在 0.8.0 里被移除了(详见 0.8 迁移指南)。 - `messageSubtype`、`metadata` 等所有 v0.6 字段保持原样 - 想自己拼 ContentPush(v0.6 时代会去 monkey-patch `buildInstantPushPayload` 的场景)现在直接用 `buildContentPush(...)` —— 它由 `@rei-standard/amsg-shared` 实现、并从 `@rei-standard/amsg-instant` 原样 re-export,不用额外加依赖 @@ -848,7 +853,7 @@ export default createCloudflareWorker((env) => ({ ``` `createCloudflareWorker` 会接住 Workers 的第三个参数 `ctx`,并把主回复链路 -(LLM 生成 → 切段 → 逐条 Web Push)注册到 `ctx.waitUntil`。这样浏览器关掉、 +(LLM 生成 → 构造/切段 push payloads → 逐条 Web Push)注册到 `ctx.waitUntil`。这样浏览器关掉、 页面切后台导致 HTTP 连接断开时,Worker 仍会尽力把这一轮推送跑完。直接把 `createInstantHandler(...)` 挂成 Worker module `fetch` 也支持同样的 `(request, env, ctx)` 形态。 @@ -978,7 +983,7 @@ await client.sendInstant({ - `normalizeAiApiUrl(apiUrl)` — 0.4.0 新增,幂等地补全 `/v1/chat/completions` - `sendWebPush({ subscription, payload, vapid, ttl?, fetch? })` — 0.3.0 新增,纯 Web Crypto 实现 - `buildVapidJwt({ audience, subject, publicKey, privateKey })` / `verifyVapidJwt(jwt, publicKey)` — 0.3.0 新增 -- `buildMultipartPushPayloads(payload, { maxChunkBytes?, id?, ttlMs? })` — next 新增,构造 generic `_multipart` transport payloads +- `buildMultipartPushPayloads(payload, { maxChunkBytes?, id?, ttlMs? })` — 0.8.0 新增,构造 generic `_multipart` transport payloads 子路径: diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index 91a829a..a989df7 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0-next.8", + "version": "0.8.0", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", @@ -84,7 +84,7 @@ "node": ">=18" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4" + "@rei-standard/amsg-shared": "0.1.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/rei-standard-amsg/instant/pnpm-lock.yaml b/packages/rei-standard-amsg/instant/pnpm-lock.yaml index f3e8c7e..d8e1b0e 100644 --- a/packages/rei-standard-amsg/instant/pnpm-lock.yaml +++ b/packages/rei-standard-amsg/instant/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@rei-standard/amsg-shared': - specifier: 0.1.0-next.3 - version: 0.1.0-next.3 + specifier: 0.1.0 + version: 0.1.0 devDependencies: tsup: specifier: ^8.0.0 @@ -190,8 +190,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@rei-standard/amsg-shared@0.1.0-next.3': - resolution: {integrity: sha512-w9x2x1ZT9+l/864z+pSlAx6WiujNolpEsVhGYWFGJweW6XVoS4bgYzKfSkTscXzjgFsv/+qmAnywULX0SDKbtw==} + '@rei-standard/amsg-shared@0.1.0': + resolution: {integrity: sha512-HyA60hLHFQTo3AY2ay0JjF2EHjimk2oToNhhuOmRRWZwk4Pu7EF3lcTa0O9b69KY4oOKdPq2qKeY7zL/RMR5lg==} engines: {node: '>=20'} '@rollup/rollup-android-arm-eabi@4.60.4': @@ -629,7 +629,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@rei-standard/amsg-shared@0.1.0-next.3': {} + '@rei-standard/amsg-shared@0.1.0': {} '@rollup/rollup-android-arm-eabi@4.60.4': optional: true diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 0f4caca..42f89d5 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -3,7 +3,7 @@ * * Stateless one-shot instant push handler. The entire lifecycle of an * instant request lives inside a single function invocation: - * parse → call LLM → split sentences → deliver Web Push → 200 OK. + * parse → call LLM → build push payloads → deliver Web Push → 200 OK. * No DB, no cron, no tenant init. Zero runtime dependencies. Pure Web * Crypto under the hood, so the same handler runs unchanged on Cloudflare * Workers (no `nodejs_compat` flag), Vercel Edge, Netlify Edge, Deno, @@ -55,33 +55,17 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * @typedef {Object} InstantHandlerOptions * @property {VapidConfig} vapid - VAPID keys for Web Push. * - * Note on per-kind split-pattern fields (request-payload, not handler options): + * Note on splitting: * - * Each `messageKind` reads its own pattern field with its own default: + * 0.8.0 removes the public `splitPattern` / `reasoningSplitPattern` + * / `errorSplitPattern` request fields. Hook callers own semantic + * splitting now: implement any custom split function inside + * `onLLMOutput`, then return the exact `pushPayloads` array to send. * - * | messageKind | field on payload | default | - * |----------------|---------------------------|-----------------------------| - * | `content` | `splitPattern` | `/([。!?!?]+)/` (split-on) | - * | `tool_request` | `splitPattern` | `/([。!?!?]+)/` (split-on) | - * | `reasoning` | `reasoningSplitPattern` | **not split** | - * | `error` | `errorSplitPattern` | **not split** | - * | free-form | — | not split | - * - * Disable semantics: an explicit `null` or `[]` disables splitting - * for that kind. The asymmetry sits in `undefined` (field absent): - * `content` / `tool_request` fall back to the default sentence - * regex; `reasoning` / `error` stay unsplit. This makes the - * default-on / default-off bucket explicit in the wire format. - * - * ToolRequestPush splitting demotes prefix chunks to `content` - * (without `toolCalls`) and binds `toolCalls` to the LAST prefix - * segment (kept as `tool_request`), so narration finishes before - * the client starts executing tools. - * - * Auto-emitted ReasoningPush (from `choices[0].message.reasoning_content`) - * and framework-built ErrorPush diagnostics (`LOOP_EXCEEDED`) both - * read the same kind-specific fields. `HOOK_THREW` is a - * special-case single-shot diagnostic and bypasses the splitter. + * The legacy non-hook path still keeps its v0.6-compatible internal + * sentence splitter (`/([。!?!?]+)/`) so old completePrompt-style + * callers retain the same burst behaviour. There is no handler-level + * `splitFn` option. * @property {string} [clientToken] - Optional shared secret. When set, requests must send a matching `X-Client-Token` header. Weak by design: the token is visible in any frontend bundle that uses it. Use `tokenSigningKey` for real auth. * @property {string} [tokenSigningKey] - Optional HMAC key. When set, `Authorization: Bearer ` is verified. * @property {CorsConfig} [cors] - CORS configuration. Defaults to `{ allowOrigin: '*' }`. Every response (including the 204 preflight short-circuit) carries the matching `Access-Control-Allow-*` headers. @@ -97,18 +81,19 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * - **v0.7 hook.** When provided, the handler switches from * the legacy one-shot path to a per-turn agentic loop. * The hook returns one of: - * `{ decision: 'finish', pushPayload }` - * `{ decision: 'tool-request', pushPayload }` + * `{ decision: 'finish', pushPayloads }` + * `{ decision: 'tool-request', pushPayloads }` * `{ decision: 'continue', nextHistory }` * `{ decision: 'skip-push' }` * See README §Agentic Loop. * @property {import('./blob-store/interface.js').BlobStoreConfig} [blobStore] - * - Optional. When the hook returns a pushPayload whose + * - Optional. When a push payload's * UTF-8 byte length exceeds `maxInlineBytes` (default * 2600), the body is written to the adapter and the SW - * receives a small `{ _blob, key, url, type? }` envelope - * instead. Without `blobStore` the over-sized payload - * throws `PayloadTooLargeError`. + * receives a small `{ _blob, key, url, messageKind?, type? }` + * envelope instead. Without `blobStore`, the default + * generic multipart transport handles JSON-safe oversized + * payloads unless disabled or over its limits. * @property {number} [maxLoopIterations=10] - Hard ceiling on in-loop `decision:'continue'` rounds within a single worker invocation. Cross-invocation `/continue` floods are the deployer's auth/rate-limit concern. * @property {boolean} [autoEmitReasoning=true] * - **v0.8 hook-path config.** When `true` (default), the @@ -123,7 +108,7 @@ const BLOB_KEY_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}- * and produce its own `buildReasoningPush(...)` envelope. * Legacy (non-hook) path always auto-emits regardless. * @property {Object} [multipart] - * - **next transport knob.** Generic multipart fallback for + * - **0.8.0 transport knob.** Generic multipart fallback for * oversized JSON-safe push payloads when no BlobStore is * configured. Applies to every `messageKind` (including * reasoning, tool_request, content, error, and custom diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 108a203..04ddb3e 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -299,8 +299,8 @@ function readReasoningContent(llmResponse) { * ReasoningPush BEFORE invoking the hook when the LLM response * includes `reasoning_content` (skippable via * `autoEmitReasoning: false`). The hook then decides via the - * same 4-decision contract; the hook's `pushPayload` is what - * `sw` will route as the kind-specific content push. + * same 4-decision contract; the hook's `pushPayloads` array is + * what `sw` will route as kind-specific pushes. * * @param {Object} payload - Validated request body. * @param {Object} ctx @@ -317,8 +317,8 @@ function readReasoningContent(llmResponse) { * @param {boolean} [ctx.autoEmitReasoning=true] - Hook path only. When * `false`, the framework will not auto-emit ReasoningPush before * invoking the hook — callers wanting reasoning emission must build - * it themselves with `buildReasoningPush` and push it via their own - * `pushPayload`. + * it themselves with `buildReasoningPush` and include it in their + * own `pushPayloads`. * @param {Object} [ctx.multipart] - Generic multipart transport fallback * for oversized JSON-safe payloads when BlobStore is not configured. * @returns {Promise} @@ -417,7 +417,7 @@ async function runLegacyInstant(payload, ctx) { // regex matches Chinese full-stop family + ASCII ./!/? clusters; the // reduce reattaches the matched delimiter to the preceding segment // (split returns interleaved [segment, delim, segment, delim, ...]). - // No caller knob — the public `splitPattern` field is gone in next.4. + // No caller knob — the public `splitPattern` field is gone in 0.8.0. const splitOutput = messageContent .split(/([。!?!?]+)/) .reduce((acc, part, i, arr) => { @@ -677,8 +677,8 @@ function assertValidDecision(decision) { if (hasSingular) { throw new TypeError( hasPlural - ? 'pushPayload (singular) is removed in next.4, use pushPayloads' - : 'pushPayload (singular) is removed in next.4, use pushPayloads: [yourPayload]' + ? 'pushPayload (singular) is removed in 0.8.0, use pushPayloads' + : 'pushPayload (singular) is removed in 0.8.0, use pushPayloads: [yourPayload]' ); } @@ -707,7 +707,7 @@ function assertValidDecision(decision) { throw new TypeError(`pushPayloads[${i}] must be a plain object, got ${stringifyForError(p)}`); } if (Object.prototype.hasOwnProperty.call(p, 'splitPattern')) { - throw new TypeError(`pushPayloads[${i}].splitPattern is removed in next.4; caller is responsible for splitting`); + throw new TypeError(`pushPayloads[${i}].splitPattern is removed in 0.8.0; caller is responsible for splitting`); } if (Object.prototype.hasOwnProperty.call(p, 'messageId')) { const id = p.messageId; @@ -732,8 +732,8 @@ function stringifyForError(value) { * - With a `blobStore` configured → write body to the store, push * a small envelope `{ _blob:true, key, url, messageKind?, type? }` * instead. - * - Without → emit `payload_too_large` and throw - * `PayloadTooLargeError`. + * - Without a `blobStore` → use generic `_multipart` when enabled; + * otherwise emit `payload_too_large` and throw `PayloadTooLargeError`. * * The envelope's `messageKind` (and legacy `type`) field is lifted * from the original payload when present, so the SW can dispatch on diff --git a/packages/rei-standard-amsg/instant/src/validation.js b/packages/rei-standard-amsg/instant/src/validation.js index 9441ffe..2bb37fb 100644 --- a/packages/rei-standard-amsg/instant/src/validation.js +++ b/packages/rei-standard-amsg/instant/src/validation.js @@ -311,7 +311,7 @@ export function validateInstantPayload(payload, opts) { return { valid: false, errorCode: 'INVALID_PAYLOAD_FORMAT', - errorMessage: `${removedField} is removed in next.4; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, + errorMessage: `${removedField} is removed in 0.8.0; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, details: { invalidFields: [removedField] }, }; } @@ -457,7 +457,7 @@ export function validateContinuePayload(payload, opts) { return { valid: false, errorCode: 'INVALID_PAYLOAD_FORMAT', - errorMessage: `${removedField} is removed in next.4; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, + errorMessage: `${removedField} is removed in 0.8.0; caller is responsible for splitting (return decision.pushPayloads with the exact pushes you want sent)`, details: { invalidFields: [removedField] }, }; } diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 1dc673e..67aebad 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -797,9 +797,9 @@ describe('validateInstantPayload hookPath flag', () => { }); }); -// ─── next.4 — decision contract: pushPayloads ────────────────────────── +// ─── 0.8.0 — decision contract: pushPayloads ─────────────────────────── -describe('next.4 — decision contract: pushPayloads', () => { +describe('0.8.0 — decision contract: pushPayloads', () => { async function dispatchHookReturn(hookReturn) { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, @@ -821,7 +821,7 @@ describe('next.4 — decision contract: pushPayloads', () => { }); assert.equal(res.status, 500); assert.equal(body.error.code, 'HOOK_THREW'); - assert.match(body.error.message, /pushPayload \(singular\) is removed in next\.4, use pushPayloads: \[yourPayload\]/); + assert.match(body.error.message, /pushPayload \(singular\) is removed in 0\.8\.0, use pushPayloads: \[yourPayload\]/); }); it('rejects when BOTH pushPayload and pushPayloads are set', async () => { @@ -832,7 +832,7 @@ describe('next.4 — decision contract: pushPayloads', () => { }); assert.equal(res.status, 500); assert.equal(body.error.code, 'HOOK_THREW'); - assert.match(body.error.message, /pushPayload \(singular\) is removed in next\.4, use pushPayloads/); + assert.match(body.error.message, /pushPayload \(singular\) is removed in 0\.8\.0, use pushPayloads/); }); it('rejects pushPayloads: [] (empty array)', async () => { @@ -852,13 +852,13 @@ describe('next.4 — decision contract: pushPayloads', () => { }); assert.equal(res.status, 500); assert.equal(body.error.code, 'HOOK_THREW'); - assert.match(body.error.message, /splitPattern is removed in next\.4/); + assert.match(body.error.message, /splitPattern is removed in 0\.8\.0/); }); }); -// ─── next.4 — pushPayloads happy paths ───────────────────────────────── +// ─── 0.8.0 — pushPayloads happy paths ────────────────────────────────── -describe('next.4 — pushPayloads happy paths', () => { +describe('0.8.0 — pushPayloads happy paths', () => { it('sends N pushes from a 3-element pushPayloads array with messageIndex/totalMessages auto-fill', async () => { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, diff --git a/packages/rei-standard-amsg/instant/test/handler.test.mjs b/packages/rei-standard-amsg/instant/test/handler.test.mjs index d9de086..bfe0f27 100644 --- a/packages/rei-standard-amsg/instant/test/handler.test.mjs +++ b/packages/rei-standard-amsg/instant/test/handler.test.mjs @@ -329,22 +329,22 @@ describe('validateInstantPayload', () => { }); }); -describe('next.4 — split-pattern fields removed', () => { +describe('0.8.0 — split-pattern fields removed', () => { it('rejects request body splitPattern with INVALID_PAYLOAD_FORMAT', () => { const r = validateInstantPayload(makeValidPayload({ splitPattern: '([。!?!?]+)' })); assert.equal(r.valid, false); assert.equal(r.errorCode, 'INVALID_PAYLOAD_FORMAT'); - assert.match(r.errorMessage, /splitPattern is removed in next\.4/); + assert.match(r.errorMessage, /splitPattern is removed in 0\.8\.0/); }); it('rejects request body reasoningSplitPattern', () => { const r = validateInstantPayload(makeValidPayload({ reasoningSplitPattern: '([。!?!?]+)' })); assert.equal(r.valid, false); - assert.match(r.errorMessage, /reasoningSplitPattern is removed in next\.4/); + assert.match(r.errorMessage, /reasoningSplitPattern is removed in 0\.8\.0/); }); it('rejects request body errorSplitPattern', () => { const r = validateInstantPayload(makeValidPayload({ errorSplitPattern: '([。!?!?]+)' })); assert.equal(r.valid, false); - assert.match(r.errorMessage, /errorSplitPattern is removed in next\.4/); + assert.match(r.errorMessage, /errorSplitPattern is removed in 0\.8\.0/); }); }); diff --git a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs index 73ab29d..7bb2f80 100644 --- a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs +++ b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs @@ -1,5 +1,5 @@ /** - * next.4 — pushPayloads-only hook decision API contract matrix. + * 0.8.0 — pushPayloads-only hook decision API contract matrix. * * Pins the 13 fixtures from spec §测试要求. */ @@ -213,7 +213,7 @@ describe('7) per-push splitPattern → HookError', () => { }); assert.equal(res.status, 500); assert.equal(body.error.code, 'HOOK_THREW'); - assert.match(body.error.message, /splitPattern is removed in next\.4/); + assert.match(body.error.message, /splitPattern is removed in 0\.8\.0/); }); }); @@ -230,7 +230,7 @@ describe('8) request body splitPattern → 400', () => { assert.equal(res.status, 400); const body = await res.json(); assert.equal(body.error.code, 'INVALID_PAYLOAD_FORMAT'); - assert.match(body.error.message, /splitPattern is removed in next\.4/); + assert.match(body.error.message, /splitPattern is removed in 0\.8\.0/); }); }); diff --git a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs index 5fdfd97..2f21362 100644 --- a/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs +++ b/packages/rei-standard-amsg/instant/test/reasoning-push.test.mjs @@ -157,8 +157,8 @@ describe('hook path — ReasoningPush auto-emission', () => { fetch: router.fetch, onEvent: (e) => events.push(e), // The hook is responsible for propagating ctx.sessionId into its - // own pushPayload — the framework does NOT auto-inject (the hook - // contract is `pushPayload: unknown`, fully caller-controlled). + // own pushPayloads — the framework does NOT auto-inject (the hook + // contract is `pushPayloads: unknown[]`, fully caller-controlled). // ctx.sessionId is exposed for exactly this purpose. onLLMOutput: (ctx) => ({ decision: 'finish', @@ -258,9 +258,9 @@ describe('hook path — ReasoningPush auto-emission', () => { }); }); -// ─── next — generic multipart transport ──────────────────────────────── +// ─── 0.8.0 — generic multipart transport ─────────────────────────────── -describe('next — generic multipart transport', () => { +describe('0.8.0 — generic multipart transport', () => { it('short reasoning ships as a single push (no chunkIndex on wire)', async () => { const router = createFetchRouter({ pushEndpoint: subKit.subscription.endpoint, diff --git a/packages/rei-standard-amsg/server/CHANGELOG.md b/packages/rei-standard-amsg/server/CHANGELOG.md index c39e22a..04c2261 100644 --- a/packages/rei-standard-amsg/server/CHANGELOG.md +++ b/packages/rei-standard-amsg/server/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog — @rei-standard/amsg-server -## 2.4.0-next.2 — Dependency bump (pre-release) +## 2.4.0 — Dependency bump -- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至 `0.1.0-next.4`。 +- 依赖更新:同步升级 `@rei-standard/amsg-shared` 至稳定版 `0.1.0`。 ## 2.4.0-next.1 — avatarUrl 软清空 (pre-release) diff --git a/packages/rei-standard-amsg/server/README.md b/packages/rei-standard-amsg/server/README.md index 51686da..14dd0aa 100644 --- a/packages/rei-standard-amsg/server/README.md +++ b/packages/rei-standard-amsg/server/README.md @@ -9,7 +9,7 @@ - 业务端点统一使用 `Authorization: Bearer ` - `send-notifications` 支持 `cronToken`(Header 或 query token) -2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略);行为已对齐 `amsg-instant`,向后兼容。 +2.2+ 的字段增量(`messages` 数组、`splitPattern`、`avatarUrl` 软清空策略)在规范的 [§6.1](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#61-ai-消息字段约束) / [§6.2](https://github.com/Tosd0/ReiStandard/blob/main/standards/active-messaging-api.md#62-avatarurl-软清空策略)。其中 `splitPattern` 是 server 调度任务的持久化配置;`amsg-instant` 0.8.0 起改为 hook 内自定义 split 函数 + `pushPayloads`。 ## 安装 @@ -78,7 +78,7 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**: 可选 `temperature?: number` 透传给 LLM:`completePrompt` 路径未传时默认 0.8(保持旧行为);`messages` 路径未传时**不发**,让上游主路径自己决定。 -## 自定义分句正则 `splitPattern`(2.3.0+) +## 自定义分句正则 `splitPattern`(server 2.3.0+) `processSingleMessage` 默认按 `/([。!?!?]+)/` 把 LLM 返回的整段文本切成多条推送(每条间隔 1.5s)。`splitPattern` 让调用方覆盖这个正则: diff --git a/packages/rei-standard-amsg/server/package.json b/packages/rei-standard-amsg/server/package.json index 0665cb3..167fa44 100644 --- a/packages/rei-standard-amsg/server/package.json +++ b/packages/rei-standard-amsg/server/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-server", - "version": "2.4.0-next.2", + "version": "2.4.0", "description": "ReiStandard Active Messaging server SDK with pluggable database adapters. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content.", "repository": { "type": "git", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4", + "@rei-standard/amsg-shared": "0.1.0", "web-push": "^3.6.7", "@netlify/blobs": "^8.1.0" }, diff --git a/packages/rei-standard-amsg/shared/CHANGELOG.md b/packages/rei-standard-amsg/shared/CHANGELOG.md index 0e49b60..8304615 100644 --- a/packages/rei-standard-amsg/shared/CHANGELOG.md +++ b/packages/rei-standard-amsg/shared/CHANGELOG.md @@ -1,6 +1,6 @@ # @rei-standard/amsg-shared -## 0.1.0-next.4 — NotificationDirective 与 Shared utilities (pre-release) +## 0.1.0 — NotificationDirective 与 Shared utilities ### New - **Shared Utilities**:新增并导出了底层工具函数 `base64UrlToBytes`, `toUint8`, 和 `concatBytes`,统一了底层依赖。 diff --git a/packages/rei-standard-amsg/shared/package.json b/packages/rei-standard-amsg/shared/package.json index 9725d89..1603bbf 100644 --- a/packages/rei-standard-amsg/shared/package.json +++ b/packages/rei-standard-amsg/shared/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-shared", - "version": "0.1.0-next.4", + "version": "0.1.0", "description": "ReiStandard Active Messaging shared types and push builders — the lowest layer (no deps on other amsg packages)", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index f5f4f7b..d22489f 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog — @rei-standard/amsg-sw -## 2.1.0-next.4 — notification.show 及 Multipart chunk store (pre-release) +## 2.1.0 — notification.show 及 Multipart chunk store ### New - **`notification.show`** 通知显示策略: 支持 `"auto"` | `"always"` | `"when-hidden"` | `false`。现在可以直接通过包级策略实现 "有可见窗口时静默,无可见窗口时弹通知" (`"when-hidden"`) 等应用场景。 diff --git a/packages/rei-standard-amsg/sw/README.md b/packages/rei-standard-amsg/sw/README.md index 7b3e8af..1e5b42d 100644 --- a/packages/rei-standard-amsg/sw/README.md +++ b/packages/rei-standard-amsg/sw/README.md @@ -92,9 +92,9 @@ navigator.serviceWorker.addEventListener('message', (e) => { 当 `amsg-instant` 检测到 payload 超过 `maxInlineBytes` 时会改发 blob envelope `{ _blob: true, key, url, messageKind?, type? }`。SW **不会** 自动 fetch blob 内容(那是 client 的职责),但仍然会按 envelope 上的 `messageKind` 分发对应事件,让 client 知道有什么类型的内容即将到达,自己决定要不要拉取。Blob envelope 也只在 `messageKind === 'content'`(或缺失)时才渲染占位通知,与普通 push 行为一致。 -### Generic multipart transport(next) +### Generic multipart transport(2.1.0+) -next 阶段移除了旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format。现在 `_multipart` 是统一 transport kind,任何原始 payload 都可以被包起来: +2.1.0 移除了旧 reasoning 专用 `chunkIndex` / `totalChunks` wire format。现在 `_multipart` 是统一 transport kind,任何原始 payload 都可以被包起来: ```json { @@ -128,7 +128,7 @@ installReiSW(self, { maxChunks: 128, cleanupIntervalMs: 15 * 60_000 }, - // (新增于 2.1.0-next.3)离线持久化等业务拦截钩子: + // (新增于 2.1.0)离线持久化等业务拦截钩子: onBusinessPayload: async (payload) => { // 收到完整 payload 时触发,由于内置在 event.waitUntil 中,能够确保离线写库完毕再允许 SW 休眠 // await db.saveIncomingMessage(payload); @@ -151,7 +151,7 @@ TTL 到期仍未收齐时,SW 会清理 pending 并广播: ### 升级注意事项 - 想给 `reasoning` / `tool_request` / `error` 弹通知的业务:SW 默认不再为它们弹通知,但可以通过设置 `payload.notification.show = "always"` 或 `"when-hidden"` 来让 SW 在包层直接弹通知。无需再强求在 app 内自绘。 -- 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑;next 版本只会把完整还原后的 reasoning payload 发给 client。 +- 应用级 SW 可以删除旧 reasoning `chunkIndex` / `totalChunks` 拼接逻辑;2.1.0+ 版本只会把完整还原后的 reasoning payload 发给 client。 - 客户端代码继续兼容只有 `installReiSW` + `REI_SW_MESSAGE_TYPE`(队列)的 2.0.x 写法——新增导出不破坏既有 API。 - 想拿到 push 类型相关的 TS 类型:从 `@rei-standard/amsg-shared` 引 `AmsgPush` 等类型(本包通过 JSDoc 引用同一份类型)。 diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index c6c30dc..405ff66 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.0-next.4", + "version": "2.1.0", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", @@ -33,7 +33,7 @@ "node": ">=20" }, "dependencies": { - "@rei-standard/amsg-shared": "0.1.0-next.4" + "@rei-standard/amsg-shared": "0.1.0" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/standards/active-messaging-api.md b/standards/active-messaging-api.md index 7d8b3aa..980eb0c 100644 --- a/standards/active-messaging-api.md +++ b/standards/active-messaging-api.md @@ -4,9 +4,9 @@ > > 版本日期:2026-05-19 > -> 对齐实现(**prerelease**,仓库 `publish-workspaces.mjs` 自动按 prerelease 版本号路由到 `next` dist-tag,不进 `latest`):`@rei-standard/amsg-shared` 0.1.0-next.0、`@rei-standard/amsg-server` 2.4.0-next.0、`@rei-standard/amsg-instant` 0.8.0-next.0、`@rei-standard/amsg-client` 2.3.0-next.0、`@rei-standard/amsg-sw` 2.1.0-next.0。安装:`npm install @rei-standard/amsg-shared@next`(其余同理)。规范条款在 prerelease 期不再改,`next` 窗口是给下游集成方端到端验证用的;契约通过后会发对应正式 minor(去掉 `-next.N` 后缀)。 +> 对齐实现(稳定版):`@rei-standard/amsg-shared` 0.1.0、`@rei-standard/amsg-server` 2.4.0、`@rei-standard/amsg-instant` 0.8.0、`@rei-standard/amsg-client` 2.3.0、`@rei-standard/amsg-sw` 2.1.0。 > -> 本轮是一次跨包协调的 minor 升级:push wire shape 统一到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器),同时移除旧的 `{ type: 'error', code: '...' }` 错误信封。包间依赖一律使用精确版本(不带 `^`),所有 `dependencies` 字段都钉死在对应的 `*-next.0`。 +> 本轮是一次跨包协调的 minor 升级:push wire shape 统一到 `@rei-standard/amsg-shared` 的 `AmsgPush` 判别联合(以 `messageKind` 为字面量类型判别器),同时移除旧的 `{ type: 'error', code: '...' }` 错误信封。包间依赖一律使用精确版本(不带 `^`),所有 `dependencies` 字段都钉死在对应的稳定版本。 ## 1. 目标与范围 @@ -35,9 +35,9 @@ **v2.x 后续增量**(端点与鉴权未变,均为 payload 层向后兼容扩展): - `messages` 数组提示词(互斥替代 `completePrompt`),见 §6.1。`amsg-server` 2.2.0+ 与 `amsg-instant` 0.5.0+ 实装。 -- `splitPattern` 自定义分句正则,见 §6.1。`amsg-server` 2.3.0+ 与 `amsg-instant` 0.6.0+ 实装。 -- `avatarUrl` 软清空策略(不合法值仅 `console.warn` 并置空,不再 400 整个任务),见 §6.2。`amsg-server` 2.3.3+ / 2.4.0-next.1+、`amsg-instant` 0.7.1+ / 0.8.0-next.1+、`amsg-client` 2.2.4+ / 2.3.0-next.1+ 实装;2.3.1 ~ 2.3.2 / 0.6.1 ~ 0.7.0 / 2.2.3 ~ 2.3.0-next.0 走老版"严格 400"。 -- **三轴 push schema 统一**(`messageKind` 判别联合 + 自动 `ReasoningPush`),见 §6.3 / §6.4。`@rei-standard/amsg-shared` 0.1.0-next.0、`amsg-server` 2.4.0-next.0、`amsg-instant` 0.8.0-next.0、`amsg-sw` 2.1.0-next.0、`amsg-client` 2.3.0-next.0 协同实装(`next` dist-tag 预发布)。旧 `{ type: 'error', code: '...' }` 错误信封同步移除。 +- `splitPattern` 自定义分句正则,见 §6.1。`amsg-server` 2.3.0+ 继续支持;`amsg-instant` 0.6.0 ~ 0.7.x 曾支持,0.8.0 起移除公共旋钮,改为 hook 内自定义 split 函数 + `pushPayloads`。 +- `avatarUrl` 软清空策略(不合法值仅 `console.warn` 并置空,不再 400 整个任务),见 §6.2。`amsg-server` 2.3.3+ / 2.4.0+、`amsg-instant` 0.7.1+ / 0.8.0+、`amsg-client` 2.2.4+ / 2.3.0+ 实装;2.3.1 ~ 2.3.2 / 0.6.1 ~ 0.7.0 / 2.2.3 走老版"严格 400"。 +- **三轴 push schema 统一**(`messageKind` 判别联合 + 自动 `ReasoningPush`),见 §6.3 / §6.4。`@rei-standard/amsg-shared` 0.1.0、`amsg-server` 2.4.0、`amsg-instant` 0.8.0、`amsg-sw` 2.1.0、`amsg-client` 2.3.0 协同实装。旧 `{ type: 'error', code: '...' }` 错误信封同步移除。 ## 3. 角色与职责 @@ -155,7 +155,7 @@ export const config = { ### 6.1 AI 消息字段约束 -当消息使用 AI(`messageType=prompted/auto`,或 `instant` 提供完整 AI 配置)时,下述字段约束统一适用于 `schedule-message`、`update-message`、`amsg-instant` handler。 +当消息使用 AI(`messageType=prompted/auto`,或 `instant` 提供完整 AI 配置)时,下述字段约束适用于 `schedule-message`、`update-message`、`amsg-instant` handler;其中 `splitPattern` 仅适用于 `amsg-server` 的调度任务,`amsg-instant` 0.8.0 的替代方式见本节末尾。 **`apiUrl`(必填,字符串)** — 完整聊天端点 URL(例:`https://api.openai.com/v1/chat/completions`)。实现方可做最小规范化(去首尾空白、去路径尾部多余 `/`),但**不应**自动补全版本路径(`/v1`)或聊天路径(`/chat/completions`)。若上游返回 `405 Method Not Allowed`,应优先判定为 URL 指向错误端点。 @@ -170,7 +170,7 @@ export const config = { **`maxTokens`(可选正整数)** — 映射到上游 `max_tokens`;不传则不指定。 -**`splitPattern`(可选,`string | string[] | null`)** — 自定义 LLM 返回文本的分句正则;默认 `/([。!?!?]+)/`。 +**`splitPattern`(仅 `amsg-server` 调度任务,可选,`string | string[] | null`)** — 自定义 LLM 返回文本的分句正则;默认 `/([。!?!?]+)/`。 字段写的是**正则 source 字符串**,不带 `/.../` 包裹、不带尾部 flag。库内部 `new RegExp(source)` 编译,**零 flags**。要替代常用 flag 效果请改写 pattern 本身: @@ -192,11 +192,43 @@ export const config = { **级联中的 no-match 兜底**:某一项 pattern 在某段上没匹配 → 该段原样传给下一项,不会被吃掉。 -**输入大小限制**:每项 ≤ 200 字符、数组 ≤ 10 项、每项必须能 `new RegExp(...)` 通过。违规 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)/ `400 INVALID_PAYLOAD_FORMAT`(amsg-instant)。 +**输入大小限制**:每项 ≤ 200 字符、数组 ≤ 10 项、每项必须能 `new RegExp(...)` 通过。违规 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)。 > 这些上限是**输入大小护栏**,不是 ReDoS 防御——6 字符的 `(a+)+$` 就能触发回溯爆炸。真正兜底的是 Worker / 运行时的 CPU 限额,加上 splitPattern 存在调用方自己的加密任务里、跑在调用方自己 LLM key 的输出上,自爆不跨租户。 -`amsg-server` 与 `amsg-instant` 两端独立实现但行为字节级一致;预校验工具:`validateLlmMessagesArray(messages)`、`validateSplitPattern(value)`。 +`amsg-server` 预校验工具:`validateLlmMessagesArray(messages)`、`validateSplitPattern(value)`。 + +**`amsg-instant` 0.8.0 的替代方式**:请求 body 上的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 都会返回 `400 INVALID_PAYLOAD_FORMAT`;hook 返回的单个 push 上也不得携带 `splitPattern`,否则走 `HOOK_THREW`。新方法不是 handler 级 `splitFn` 配置,而是在 `onLLMOutput` 内调用业务自己的 split 函数,并返回完整 `pushPayloads`: + +```js +function splitForPush(text) { + const segments = text.split(/([。!?!?]+)/) + .reduce((acc, part, i, arr) => { + if (i % 2 === 0 && part.trim()) acc.push(part.trim() + (arr[i + 1] || '')); + return acc; + }, []) + .filter((s) => s.length > 0); + return segments.length > 0 ? segments : [text]; +} + +createInstantHandler({ + vapid, + onLLMOutput(ctx) { + const segments = splitForPush(ctx.llmOutputText); + return { + decision: 'finish', + pushPayloads: segments.map((message) => ({ + messageKind: 'content', + sessionId: ctx.sessionId, + message, + notification: { title: `来自 ${ctx.contactName}`, body: message } + })) + }; + } +}); +``` + +`amsg-instant` 的非 hook legacy 路径仍保留内部默认句切 `/([。!?!?]+)/`,用于保持 0.6/0.7 时代的 completePrompt 行为;只是这个内部切分不再暴露请求级配置。 ### 6.2 `avatarUrl` 软清空策略 @@ -207,13 +239,13 @@ export const config = { - **不接受** 长度 > 2048 字符的 URL。 - `undefined` / `null` 视为"未传",零行为变化。 -**处理方式(amsg-server 2.3.3+ / 2.4.0-next.1+,amsg-instant 0.7.1+ / 0.8.0-next.1+,amsg-client 2.2.4+ / 2.3.0-next.1+)**:头像是装饰性字段,单独一个不合法 URL 不应该把整条推送 fail 掉。所以服务端 / 客户端遇到上面任何不合法情形,**不返回 4xx**,而是: +**处理方式(amsg-server 2.3.3+ / 2.4.0+,amsg-instant 0.7.1+ / 0.8.0+,amsg-client 2.2.4+ / 2.3.0+)**:头像是装饰性字段,单独一个不合法 URL 不应该把整条推送 fail 掉。所以服务端 / 客户端遇到上面任何不合法情形,**不返回 4xx**,而是: 1. 把 `avatarUrl` 在 payload 上**置为 `null`**(schedule / instant 路径);`update-message` 路径则**从 patch 里删掉**该字段,已存储的旧头像保持不变。 2. 在控制台 `console.warn` 出原因(含建议,如"请改为公网可访问的 https:// 图片 URL")。 3. 继续处理 payload 其它字段。 -老版本(`amsg-server` 2.3.1 ~ 2.3.2 / 2.4.0-next.0、`amsg-instant` 0.6.1 ~ 0.7.0 / 0.8.0-next.0、`amsg-client` 2.2.3 / 2.3.0-next.0)走严格 400: +老版本(`amsg-server` 2.3.1 ~ 2.3.2、`amsg-instant` 0.6.1 ~ 0.7.0、`amsg-client` 2.2.3)走严格 400: - `amsg-server.schedule-message` → `400 INVALID_PARAMETERS` - `amsg-server.update-message` → `400 INVALID_UPDATE_DATA` - `amsg-instant` → `400 INVALID_PAYLOAD_FORMAT` @@ -277,7 +309,7 @@ LLM 思考过程,从 `choices[0].message.reasoning_content` 提升而来。 #### 6.3.4 `ToolRequestPush`(`messageKind: 'tool_request'`) -由 agentic-loop 钩子返回 `{ decision: 'tool-request', pushPayload }` 触发。 +由 agentic-loop 钩子返回 `{ decision: 'tool-request', pushPayloads }` 触发。`pushPayloads` 可以是一条或多条 push,框架会按数组顺序依次发送。 | 字段 | 类型 | 说明 | |---|---|---| @@ -459,9 +491,9 @@ v2.0.1(破坏性): v2.x 后续增量(向后兼容,无需迁移): - `messages` 数组(2.2.0+):未使用此字段的调用方零修改。 -- `splitPattern`(2.3.0+):未传时走默认正则,老库存任务字段缺失也按默认处理。 +- `splitPattern`(server 2.3.0+):未传时走默认正则,老库存任务字段缺失也按默认处理。`amsg-instant` 0.8.0 起移除请求级 `splitPattern`,迁移到 `onLLMOutput` 内自定义 split 函数 + `pushPayloads`。 - `avatarUrl` 严格校验(2.3.1 ~ 2.3.2):之前传 `data:` URI 当 avatarUrl 实际上一直推不出来(触发下游 4KB / 413),收紧到入口立即报错而已;从未推成功的调用者无感升级。 -- `avatarUrl` 软清空(server 2.3.3+ / 2.4.0-next.1+,instant 0.7.1+ / 0.8.0-next.1+,client 2.2.4+ / 2.3.0-next.1+):把"严格 400"放宽为"`console.warn` + 置空 + 继续"。整条推送不再因为一个装饰性字段挂掉;之前依赖 400 报错的调用方只需改成观察 `console.warn`。详见 §6.2。 +- `avatarUrl` 软清空(server 2.3.3+ / 2.4.0+,instant 0.7.1+ / 0.8.0+,client 2.2.4+ / 2.3.0+):把"严格 400"放宽为"`console.warn` + 置空 + 继续"。整条推送不再因为一个装饰性字段挂掉;之前依赖 400 报错的调用方只需改成观察 `console.warn`。详见 §6.2。 ## 12. 实现一致性要求(DoD) @@ -470,7 +502,7 @@ v2.x 后续增量(向后兼容,无需迁移): 1. 租户初始化为一步(`init-tenant`)。 2. 业务端点不可仅依赖 `X-User-Id` 调用成功。 3. `tenantToken` 与 `cronToken` 权限分离。 -4. `amsg-server` 与 `amsg-instant` 在共有字段(§6.1 / §6.2)上行为字节级一致。`examples/` 是教学示例,可能滞后于最新 SDK 字段,不在一致性约束内。 +4. `amsg-server` 与 `amsg-instant` 在共有字段(§6.1 / §6.2)上行为字节级一致;`splitPattern` 不是 0.8.0 instant 的共有字段。`examples/` 是教学示例,可能滞后于最新 SDK 字段,不在一致性约束内。 5. 文档明确管理员一次性与租户一次性职责。 6. 若实现推荐调度模式,必须实现 Blob 租户调度索引,并对索引中的 `cronToken` 加密存储。 7. 若同时启用兼容模式与推荐模式,必须实现调度防重入机制(入口互斥或任务原子领取)。 diff --git a/standards/service-worker-specification.md b/standards/service-worker-specification.md index dbeaea6..21f1cb8 100644 --- a/standards/service-worker-specification.md +++ b/standards/service-worker-specification.md @@ -5,8 +5,8 @@ ## 版本信息 -- **版本号**: v2.0.1 -- **最后更新**: 2026-02-23 +- **版本号**: v2.1.0 +- **最后更新**: 2026-05-25 - **状态**: Stable - **关联标准**: [主动消息API端点标准](./active-messaging-api.md) @@ -341,27 +341,43 @@ self.addEventListener('push', event => { }; } - // 构建通知选项 - const options = buildNotificationOptions(notificationData); + event.waitUntil((async () => { + const clients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }).catch(() => []); - // 显示通知 - event.waitUntil( - self.registration.showNotification(options.title, options) - .then(() => { - console.log('[SW] 通知已显示:', options.title); - }) - .catch(error => { - console.error('[SW] 通知显示失败:', error); - }) - ); + // 根据 messageKind 和 notification.show 判断是否显示通知 + const kind = notificationData.messageKind || 'content'; + const showPolicy = notificationData.notification?.show || 'auto'; + const hasVisibleClient = clients.some(client => client.visibilityState === 'visible'); + const shouldShow = + showPolicy === 'always' || + (showPolicy === 'when-hidden' && !hasVisibleClient) || + (showPolicy === 'auto' && kind === 'content'); + + if (!shouldShow || showPolicy === false) return; + + // 构建通知选项 + const options = buildNotificationOptions(notificationData); + + // 如果需要显示通知,则调用 showNotification + try { + await self.registration.showNotification(options.title, options); + console.log('[SW] 通知已显示:', options.title); + } catch (error) { + console.error('[SW] 通知显示失败:', error); + } + })()); }); // 辅助函数:构建通知选项 function buildNotificationOptions(data) { + const notif = data.notification || {}; return { - title: data.title || `来自 ${data.contactName}`, - body: data.message || '您有一条新消息', - icon: data.avatarUrl || '/icons/default-avatar.png', + title: notif.title || data.title || `来自 ${data.contactName}`, + body: notif.body || data.message || '您有一条新消息', + icon: notif.icon || data.avatarUrl || '/icons/default-avatar.png', badge: '/icons/badge.png', tag: data.messageId || `msg-${Date.now()}`, data: { @@ -417,7 +433,7 @@ function buildNotificationOptions(data) { **核心原则**: - **后台状态**: 用户未打开应用或切换到其他应用时,显示系统通知 -- **前台状态**: 用户正在使用应用时,直接在 UI 中渲染消息,不显示系统通知 +- **前台状态**: 用户正在使用应用时,建议直接在 UI 中渲染消息;若希望前台不弹系统通知,请让 payload 设置 `notification.show = "when-hidden"` 或 `false` - **渲染一致性**: 前台接收的消息应与实时收到的消息在视觉和交互上完全一致 #### 实现方式 @@ -453,7 +469,7 @@ self.addEventListener('push', async event => { return client.visibilityState === 'visible' && client.focused; }); - // 2. 如果用户在前台,直接发送消息给页面,不弹通知 + // 2. 如果用户在前台,直接发送消息给页面 if (visibleClients.length > 0) { console.log('[SW] 用户在前台,发送消息到页面'); @@ -466,14 +482,23 @@ self.addEventListener('push', async event => { }); }); - // 不显示系统通知 - return; + // 如果希望前台不弹系统通知,请让 payload.notification.show = "when-hidden" 或 false } - // 3. 如果用户在后台,显示系统通知 - console.log('[SW] 用户在后台,显示系统通知'); - const options = buildNotificationOptions(notificationData); - await self.registration.showNotification(options.title, options); + // 3. 检查 messageKind 和 notification.show 判断是否要在后台弹系统通知 + const kind = notificationData.messageKind || 'content'; + const showPolicy = notificationData.notification?.show || 'auto'; + const hasVisibleClient = allClients.some(client => client.visibilityState === 'visible'); + const shouldShow = + showPolicy === 'always' || + (showPolicy === 'when-hidden' && !hasVisibleClient) || + (showPolicy === 'auto' && kind === 'content'); + + if (showPolicy !== false && shouldShow) { + console.log('[SW] 显示系统通知'); + const options = buildNotificationOptions(notificationData); + await self.registration.showNotification(options.title, options); + } })() ); }); @@ -1313,14 +1338,29 @@ self.addEventListener('push', (event) => { data = { title: '新消息', message: '您有一条新消息' }; } - event.waitUntil( - self.registration.showNotification(data.title || '新消息', { - body: data.message || '您有一条新消息', - icon: data.avatarUrl || '/icons/icon-192.png', - badge: '/icons/badge.png', + event.waitUntil((async () => { + const clients = await self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }).catch(() => []); + const kind = data.messageKind || 'content'; + const notif = data.notification || {}; + const showPolicy = notif.show || 'auto'; + const hasVisibleClient = clients.some(client => client.visibilityState === 'visible'); + const shouldShow = + showPolicy === 'always' || + (showPolicy === 'when-hidden' && !hasVisibleClient) || + (showPolicy === 'auto' && kind === 'content'); + + if (!shouldShow || showPolicy === false) return; + + await self.registration.showNotification(notif.title || data.title || '新消息', { + body: notif.body || data.message || '您有一条新消息', + icon: notif.icon || data.avatarUrl || '/icons/icon-192.png', + badge: notif.badge || '/icons/badge.png', data - }) - ); + }); + })()); }); self.addEventListener('notificationclick', (event) => { @@ -1335,6 +1375,24 @@ self.addEventListener('notificationclick', (event) => { ## 15. 变更日志 +### v2.1.0 (2026-05-25) + +#### 🔧 改进优化 + +**1. 三轴 Push Schema 与 `messageKind`** +- 引入了 `messageKind` 属性,区分 `content`、`reasoning`、`tool_request`、`error` 类型的推送。 +- Service Worker 默认仅在 `messageKind === 'content'` 时显示系统通知,其他类型默认不弹窗,需业务前端接收处理。 + +**2. 通知显示策略 (`notification.show`)** +- 支持通过 `notification.show` 显式控制系统通知行为(`auto`, `always`, `when-hidden`, `false`)。 +- 进一步提升前台应用接管消息的自由度,免除不必要的通知打扰。 + +**3. `onBusinessPayload` 与 Generic Multipart(SDK 功能)** +- 统一了分片数据还原逻辑,移除了老的 `chunkIndex` 专属逻辑,改为基于 `_multipart` 进行可靠重组。 +- 提供了 `onBusinessPayload` 钩子能力,安全拦截落地完整业务负载。 + +--- + ### v2.0.1 (2026-02-23) #### 🔧 改进优化 From 8fc79206b4cfaf76f1475c6cd7c7968efcb9ae57 Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 26 May 2026 16:19:46 +0800 Subject: [PATCH 32/33] feat(amsg-instant): implement text segmentation tool with protected blocks --- .../rei-standard-amsg/instant/CHANGELOG.md | 4 + packages/rei-standard-amsg/instant/README.md | 47 ++++++ .../rei-standard-amsg/instant/package.json | 2 +- .../rei-standard-amsg/instant/src/index.js | 2 + .../instant/src/segmentation.js | 125 ++++++++++++++++ .../instant/test/segmentation.test.mjs | 139 ++++++++++++++++++ standards/active-messaging-api.md | 2 + 7 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 packages/rei-standard-amsg/instant/src/segmentation.js create mode 100644 packages/rei-standard-amsg/instant/test/segmentation.test.mjs diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index d4ed818..7a74fc5 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @rei-standard/amsg-instant +## 0.8.1 — segmentTextWithProtectedBlocks utility + +- **New**: 增加包级独立 utility `segmentTextWithProtectedBlocks`。该工具用于帮助 caller 将带有“不可拆片段”(如 Markdown 代码块、特定标记)的文本切分为 `PushTextSegment` 数组。纯正则匹配保护机制,不引入业务耦合,并支持自定义 preview 与 metadata,帮助更安全、方便地构建 hook 的 `pushPayloads` 返回值。 + ## 0.8.0 — waitUntil lifecycle support - 稳定版发布:`0.8.0-next.*` 能力毕业到 latest,依赖收敛到 `@rei-standard/amsg-shared@0.1.0`。 diff --git a/packages/rei-standard-amsg/instant/README.md b/packages/rei-standard-amsg/instant/README.md index b1f2c76..647790b 100644 --- a/packages/rei-standard-amsg/instant/README.md +++ b/packages/rei-standard-amsg/instant/README.md @@ -350,6 +350,53 @@ return { 请求 body 上的 `splitPattern` / `reasoningSplitPattern` / `errorSplitPattern` 在 0.8.0 里直接 400;push 上带 `splitPattern` 抛 `HookError`。迁移时把旧正则逻辑搬进 hook 内的自定义 split 函数,再返回一组 `pushPayloads`。 +#### 通用保护切分工具 `segmentTextWithProtectedBlocks` + +对于含有不可切碎内容(例如 markdown 代码块、自定义标记)的场景,你可以使用包提供的 `segmentTextWithProtectedBlocks` 帮助构造。它**不是业务解析器**,也不预设任何业务标签,而是纯粹基于正则帮你保护不想被普通 `splitText` 切断的文本片段。 + +```js +import { segmentTextWithProtectedBlocks } from '@rei-standard/amsg-instant'; + +const segments = segmentTextWithProtectedBlocks(ctx.llmOutputText, { + // 基础文本怎么切 + splitText: (text) => text.split('\n'), + // 对截取后的基础文本进行预处理(可选) + sanitizeText: (text) => text.trim(), + + // 遇到这些 pattern 匹配到的片段不切碎,原样作为一个独立块保留 + protectedPatterns: [ + { + pattern: /```[\s\S]*?```/, + preview: '[Code Block]', // OS 通知栏看到的替代文字 + }, + { + pattern: /[\s\S]*?<\/think>/, + preview: (raw) => '[Thought Process]', // 支持固定字符串或函数 + meta: { type: 'think-block' } // 可选元数据,输出时会附加到 segment 上 + } + ] +}); + +// 输出的片段数组形如: +// [ +// { raw: '文本第一段', sanitized: '文本第一段', protect: false }, +// { raw: '```js\nconst a = 1;\n```', sanitized: '[Code Block]', protect: true }, +// { raw: '后续文本', sanitized: '后续文本', protect: false } +// ] + +return { + decision: 'finish', + pushPayloads: segments.map((seg) => ({ + messageKind: 'content', + sessionId: ctx.sessionId, + message: seg.raw, + // 保护段可以在通知栏显示短 preview,但 payload 原样发给客户端渲染 + notification: { title: `来自 ${ctx.contactName}`, body: seg.sanitized }, + metadata: seg.meta + })), +}; +``` + #### 例 1:单 push ```js diff --git a/packages/rei-standard-amsg/instant/package.json b/packages/rei-standard-amsg/instant/package.json index a989df7..ffc9498 100644 --- a/packages/rei-standard-amsg/instant/package.json +++ b/packages/rei-standard-amsg/instant/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-instant", - "version": "0.8.0", + "version": "0.8.1", "description": "ReiStandard Active Messaging — agentic-loop framework for instant push. Pluggable per-turn hook + optional blob envelope for oversize payloads. Three-axis push schema (messageKind / messageType / messageSubtype) from @rei-standard/amsg-shared. Auto-emits ReasoningPush when the LLM response carries reasoning_content. Pure Web Crypto. Deployable to Cloudflare Workers / Vercel Edge / Netlify / Node with no flags.", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/instant/src/index.js b/packages/rei-standard-amsg/instant/src/index.js index 42f89d5..bd968e0 100644 --- a/packages/rei-standard-amsg/instant/src/index.js +++ b/packages/rei-standard-amsg/instant/src/index.js @@ -647,3 +647,5 @@ export { isErrorPush, chunkReasoningByUtf8Bytes, } from '@rei-standard/amsg-shared'; + +export { segmentTextWithProtectedBlocks } from './segmentation.js'; diff --git a/packages/rei-standard-amsg/instant/src/segmentation.js b/packages/rei-standard-amsg/instant/src/segmentation.js new file mode 100644 index 0000000..7f89264 --- /dev/null +++ b/packages/rei-standard-amsg/instant/src/segmentation.js @@ -0,0 +1,125 @@ +/** + * @typedef {Object} ProtectedPattern + * @property {RegExp} pattern + * @property {string | ((raw: string, match: RegExpMatchArray) => string)} [preview] + * @property {unknown | ((raw: string, match: RegExpMatchArray) => unknown)} [meta] + */ + +/** + * @typedef {Object} SegmentTextOptions + * @property {ProtectedPattern[]} [protectedPatterns] + * @property {(text: string) => string[]} splitText + * @property {(text: string) => string} [sanitizeText] + */ + +/** + * @typedef {Object} PushTextSegment + * @property {string} raw + * @property {string} sanitized + * @property {boolean} protect + * @property {unknown} [meta] + */ + +/** + * Segments text while protecting specific blocks from being split. + * This is useful when the output needs to be split for streaming or chunking, + * but certain segments (e.g. Markdown code blocks, HTML tags) must be kept intact. + * + * @param {string} text + * @param {SegmentTextOptions} options + * @returns {PushTextSegment[]} + */ +export function segmentTextWithProtectedBlocks(text, options) { + if (!text) return []; + + const splitAndSanitize = (plainText) => { + const chunks = options.splitText(plainText).filter(c => c !== ''); + return chunks.map(chunk => ({ + raw: chunk, + sanitized: options.sanitizeText ? options.sanitizeText(chunk) : chunk, + protect: false + })); + }; + + if (!options.protectedPatterns || options.protectedPatterns.length === 0) { + return splitAndSanitize(text); + } + + const matches = []; + + for (const p of options.protectedPatterns) { + // Clone the regex to ensure we can iterate globally + const flags = p.pattern.flags.includes('g') ? p.pattern.flags : p.pattern.flags + 'g'; + const regex = new RegExp(p.pattern.source, flags); + + let match; + while ((match = regex.exec(text)) !== null) { + if (match[0].length === 0) { + regex.lastIndex++; + continue; + } + matches.push({ + index: match.index, + length: match[0].length, + raw: match[0], + matchObj: match, + patternDef: p + }); + } + } + + // Sort by earliest match first, then by longest match + matches.sort((a, b) => { + if (a.index !== b.index) return a.index - b.index; + return b.length - a.length; + }); + + // Resolve overlaps + const validMatches = []; + let lastEnd = 0; + for (const m of matches) { + if (m.index >= lastEnd) { + validMatches.push(m); + lastEnd = m.index + m.length; + } + } + + const segments = []; + let cursor = 0; + + const resolveField = (field, raw, match) => typeof field === 'function' ? field(raw, match) : field; + + for (const m of validMatches) { + if (m.index > cursor) { + const plainText = text.substring(cursor, m.index); + segments.push(...splitAndSanitize(plainText)); + } + + const previewStr = resolveField(m.patternDef.preview, m.raw, m.matchObj); + const metaData = resolveField(m.patternDef.meta, m.raw, m.matchObj); + + let sanitized = previewStr; + if (sanitized == null) { + sanitized = options.sanitizeText ? options.sanitizeText(m.raw) : m.raw; + } + + const pushSeg = { + raw: m.raw, + sanitized, + protect: true + }; + if (metaData !== undefined) { + pushSeg.meta = metaData; + } + segments.push(pushSeg); + + cursor = m.index + m.length; + } + + if (cursor < text.length) { + const plainText = text.substring(cursor); + segments.push(...splitAndSanitize(plainText)); + } + + return segments; +} diff --git a/packages/rei-standard-amsg/instant/test/segmentation.test.mjs b/packages/rei-standard-amsg/instant/test/segmentation.test.mjs new file mode 100644 index 0000000..9ffe824 --- /dev/null +++ b/packages/rei-standard-amsg/instant/test/segmentation.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { segmentTextWithProtectedBlocks } from '../src/segmentation.js'; + +test('segmentation utility', async (t) => { + const defaultOptions = { + splitText: (text) => text.split('\n'), + sanitizeText: (text) => text.trim(), + }; + + await t.test('1. splits normal text with splitText', () => { + const text = 'hello\nworld\n!'; + const result = segmentTextWithProtectedBlocks(text, defaultOptions); + assert.deepEqual(result, [ + { raw: 'hello', sanitized: 'hello', protect: false }, + { raw: 'world', sanitized: 'world', protect: false }, + { raw: '!', sanitized: '!', protect: false }, + ]); + }); + + await t.test('2. single multi-line protected block is not split', () => { + const text = '```html\n
\n test\n
\n```'; + const result = segmentTextWithProtectedBlocks(text, { + ...defaultOptions, + protectedPatterns: [ + { pattern: /```[\s\S]*?```/ } + ] + }); + assert.deepEqual(result, [ + { + raw: '```html\n
\n test\n
\n```', + sanitized: '```html\n
\n test\n
\n```', + protect: true + } + ]); + }); + + await t.test('3. plain text around protected block is split normally', () => { + const text = 'line 1\n```code\nline 2\nline 3\n```\nline 4'; + const result = segmentTextWithProtectedBlocks(text, { + ...defaultOptions, + protectedPatterns: [ + { pattern: /```[\s\S]*?```/ } + ] + }); + assert.deepEqual(result, [ + { raw: 'line 1', sanitized: 'line 1', protect: false }, + { raw: '```code\nline 2\nline 3\n```', sanitized: '```code\nline 2\nline 3\n```', protect: true }, + { raw: 'line 4', sanitized: 'line 4', protect: false }, + ]); + }); + + await t.test('4. multiple sequential protected blocks maintain order', () => { + const text = '[[A]]\nbetween\n[[B]]'; + const result = segmentTextWithProtectedBlocks(text, { + ...defaultOptions, + protectedPatterns: [ + { pattern: /\[\[.*?\]\]/ } + ] + }); + assert.deepEqual(result, [ + { raw: '[[A]]', sanitized: '[[A]]', protect: true }, + { raw: 'between', sanitized: 'between', protect: false }, + { raw: '[[B]]', sanitized: '[[B]]', protect: true }, + ]); + }); + + await t.test('5. preview is used for sanitized, but raw keeps original text', () => { + const text = '\nprocessing...\n\nanswer'; + const result = segmentTextWithProtectedBlocks(text, { + ...defaultOptions, + protectedPatterns: [ + { + pattern: /[\s\S]*?<\/think>/, + preview: '[Thought Process]', + meta: { type: 'thought' } + } + ] + }); + assert.deepEqual(result, [ + { + raw: '\nprocessing...\n', + sanitized: '[Thought Process]', + protect: true, + meta: { type: 'thought' } + }, + { raw: 'answer', sanitized: 'answer', protect: false } + ]); + }); + + await t.test('6. fallbacks to sanitizeText/raw when preview is not provided', () => { + const text = '<>'; + const result = segmentTextWithProtectedBlocks(text, { + splitText: (t) => [t], + sanitizeText: (t) => t.replace(/[<>]/g, ''), + protectedPatterns: [ + { pattern: /<<.*?>>/ } + ] + }); + assert.deepEqual(result, [ + { + raw: '<>', + sanitized: 'RAW', // Fallback to sanitizeText + protect: true + } + ]); + }); + + await t.test('7. acts like splitText + sanitizeText when protectedPatterns is omitted', () => { + const text = 'foo\nbar'; + const result = segmentTextWithProtectedBlocks(text, defaultOptions); + assert.deepEqual(result, [ + { raw: 'foo', sanitized: 'foo', protect: false }, + { raw: 'bar', sanitized: 'bar', protect: false } + ]); + }); + + await t.test('8. handles overlapping patterns (earliest match wins, then longest)', () => { + // Pattern 1: [A-B] + // Pattern 2: [A-C] + // Text: [A-C] + const text = '123456'; + const result = segmentTextWithProtectedBlocks(text, { + ...defaultOptions, + splitText: (t) => [t], + protectedPatterns: [ + { pattern: /234/, preview: 'short' }, + { pattern: /2345/, preview: 'long' } + ] + }); + + // Earliest match is at index 1 for both. The longest should win (2345). + assert.deepEqual(result, [ + { raw: '1', sanitized: '1', protect: false }, + { raw: '2345', sanitized: 'long', protect: true }, + { raw: '6', sanitized: '6', protect: false } + ]); + }); +}); diff --git a/standards/active-messaging-api.md b/standards/active-messaging-api.md index 980eb0c..ff70e36 100644 --- a/standards/active-messaging-api.md +++ b/standards/active-messaging-api.md @@ -228,6 +228,8 @@ createInstantHandler({ }); ``` +对于需要保护特定片段(如 Markdown 代码块)不被切碎的复杂场景,包提供了一个纯工具函数 `segmentTextWithProtectedBlocks` 协助处理分段,但其调用仍位于 hook 内部,不改变 payload 契约。 + `amsg-instant` 的非 hook legacy 路径仍保留内部默认句切 `/([。!?!?]+)/`,用于保持 0.6/0.7 时代的 completePrompt 行为;只是这个内部切分不再暴露请求级配置。 ### 6.2 `avatarUrl` 软清空策略 From 0b923e9bf5f164edbd8c440db095e204f2301feb Mon Sep 17 00:00:00 2001 From: Tosd0 <65720409+Tosd0@users.noreply.github.com> Date: Tue, 26 May 2026 21:18:25 +0800 Subject: [PATCH 33/33] fix(amsg): harden push transport edge cases --- package-lock.json | 4 +- .../rei-standard-amsg/instant/CHANGELOG.md | 1 + .../instant/src/message-processor.js | 6 +- .../instant/test/agentic-loop.test.mjs | 2 +- .../instant/test/pushpayloads-array.test.mjs | 18 ++++++ packages/rei-standard-amsg/sw/CHANGELOG.md | 7 ++- packages/rei-standard-amsg/sw/package.json | 2 +- packages/rei-standard-amsg/sw/src/index.js | 28 +++++++-- .../sw/test/dispatch.test.mjs | 59 ++++++++++++++++++- 9 files changed, 113 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ae4251d..3bb03cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1865,7 +1865,7 @@ }, "packages/rei-standard-amsg/instant": { "name": "@rei-standard/amsg-instant", - "version": "0.8.0", + "version": "0.8.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0" @@ -1923,7 +1923,7 @@ }, "packages/rei-standard-amsg/sw": { "name": "@rei-standard/amsg-sw", - "version": "2.1.0", + "version": "2.1.1", "license": "MIT", "dependencies": { "@rei-standard/amsg-shared": "0.1.0" diff --git a/packages/rei-standard-amsg/instant/CHANGELOG.md b/packages/rei-standard-amsg/instant/CHANGELOG.md index 7a74fc5..17e4ef6 100644 --- a/packages/rei-standard-amsg/instant/CHANGELOG.md +++ b/packages/rei-standard-amsg/instant/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.8.1 — segmentTextWithProtectedBlocks utility - **New**: 增加包级独立 utility `segmentTextWithProtectedBlocks`。该工具用于帮助 caller 将带有“不可拆片段”(如 Markdown 代码块、特定标记)的文本切分为 `PushTextSegment` 数组。纯正则匹配保护机制,不引入业务耦合,并支持自定义 preview 与 metadata,帮助更安全、方便地构建 hook 的 `pushPayloads` 返回值。 +- **Fix**: hook 返回的 `pushPayloads` 现在会在发送前浅拷贝再自动补齐 `messageId` / `messageIndex` / `totalMessages`,避免原地修改 caller 对象,并支持 `Object.freeze(...)` 这类不可变 payload。 ## 0.8.0 — waitUntil lifecycle support diff --git a/packages/rei-standard-amsg/instant/src/message-processor.js b/packages/rei-standard-amsg/instant/src/message-processor.js index 04ddb3e..453a281 100644 --- a/packages/rei-standard-amsg/instant/src/message-processor.js +++ b/packages/rei-standard-amsg/instant/src/message-processor.js @@ -47,8 +47,8 @@ const PUSH_PAYLOAD_BYTE_ENCODER = new TextEncoder(); * pushes. Each push goes through `sendPushWithMaybeBlob` so the blob * detour still applies per-push. * - * Per-push auto-fill (mutates the push object in place — the hook - * returned a plain literal, we own it from this point): + * Per-push auto-fill (copies each hook-returned object before enriching + * the transport payload): * - `messageId` — only when the hook didn't set one (auto-fill * with `msg__chunk_` so deduplication * on the SW side works across retries). @@ -69,7 +69,7 @@ const PUSH_PAYLOAD_BYTE_ENCODER = new TextEncoder(); async function sendPushesSequentially(pushPayloads, payload, ctx, sessionId, sleep) { const total = pushPayloads.length; for (let i = 0; i < total; i++) { - const push = pushPayloads[i]; + const push = { ...pushPayloads[i] }; if (push.messageId === undefined) { push.messageId = `msg_${randomUUID()}_chunk_${i}`; } diff --git a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs index 67aebad..598c5dd 100644 --- a/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs +++ b/packages/rei-standard-amsg/instant/test/agentic-loop.test.mjs @@ -373,7 +373,7 @@ describe('sendPushWithMaybeBlob — byte boundary', () => { }); const blobAdapter = createMemoryBlobStore(); // Build a JSON string of *exactly* `len` UTF-8 bytes after the - // sendPushesSequentially auto-fill mutates the push object. + // sendPushesSequentially auto-fill enriches the transport copy. // Final shape: {"type":"x","p":"...","messageId":"msg__chunk_0","messageIndex":1,"totalMessages":1} // messageId is the only variable-width field; its UUID is 36 chars, // so messageId value length is `msg_`.length + 36 + `_chunk_0`.length = 48. diff --git a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs index 7bb2f80..e98ec19 100644 --- a/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs +++ b/packages/rei-standard-amsg/instant/test/pushpayloads-array.test.mjs @@ -105,6 +105,24 @@ describe('1) pushPayloads.length === 1', () => { assert.deepEqual(pushes[0].notification, { title: 'Rei', body: 'hi' }); assert.deepEqual(sleeps, []); }); + + it('accepts frozen push payload objects by enriching a shallow copy', async () => { + const frozenPush = Object.freeze({ + messageKind: 'content', + message: 'frozen but valid', + }); + const { result, pushes } = await runDirect({ + decision: 'finish', + pushPayloads: [frozenPush], + }, { autoEmitReasoning: false }); + + assert.equal(result.status, 'finished'); + assert.equal(pushes.length, 1); + assert.equal(pushes[0].message, 'frozen but valid'); + assert.equal(pushes[0].messageIndex, 1); + assert.equal(pushes[0].totalMessages, 1); + assert.equal(Object.prototype.hasOwnProperty.call(frozenPush, 'messageIndex'), false); + }); }); // 2) Three-push multi-burst with 1500ms spacing diff --git a/packages/rei-standard-amsg/sw/CHANGELOG.md b/packages/rei-standard-amsg/sw/CHANGELOG.md index d22489f..0eff2ee 100644 --- a/packages/rei-standard-amsg/sw/CHANGELOG.md +++ b/packages/rei-standard-amsg/sw/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog — @rei-standard/amsg-sw +## 2.1.1 — multipart 并发与 hook thenable 修复 + +- **Fix**: `_multipart` reassembly 现在按 multipart id 串行处理分片,避免并发 push delivery 下 IndexedDB read-modify-write 交错导致 `receivedCount` / `receivedBytes` 丢写,最终卡住重组。 +- **Fix**: `onBusinessPayload` 现在识别通用 thenable,并通过 `Promise.resolve(...)` 纳入 `event.waitUntil` 生命周期,不再只接受同 realm 的 `Promise` 实例。 + ## 2.1.0 — notification.show 及 Multipart chunk store ### New @@ -9,7 +14,7 @@ - **性能优化**:`dispatchBusinessPayload` 现在只会调用一次 `sw.clients.matchAll` 从而避免多余的 IPC 开销。 - **IndexedDB 性能优化**:通过 `cachedDB` 保持 DB 连接,防止碎片化的 `openQueueDatabase` 导致的延迟。`REI_SW_DB_VERSION` 升级至 `3`。 - **Multipart Chunk Store**:新增 `multipart-chunk` object store 用于独立存储分片的 payload,提升了超大 payload 还原的内存稳定性和入库速度。添加了 `expiresAt` 索引大幅加速清理超时数据的过程。 -- **去处硬编码**:移除了 `createNotificationFromPayload` 中硬编码的 “来自 {contactName}” 兜底,现在只会优雅地 fallback 到 `payload.contactName`。使用 `amsg-shared` 导出的 `MESSAGE_KIND` 枚举替代了魔法字符串。 +- **通知标题兜底**:恢复 `createNotificationFromPayload` 中 `来自 {contactName}` 的标题 fallback,避免 custom hook 只传 `contactName` 时显示裸名字。使用 `amsg-shared` 导出的 `MESSAGE_KIND` 枚举替代了魔法字符串。 ## 2.1.0-next.3 — 新增 `onBusinessPayload` 离线钩子 (pre-release) diff --git a/packages/rei-standard-amsg/sw/package.json b/packages/rei-standard-amsg/sw/package.json index 405ff66..f655dc4 100644 --- a/packages/rei-standard-amsg/sw/package.json +++ b/packages/rei-standard-amsg/sw/package.json @@ -1,6 +1,6 @@ { "name": "@rei-standard/amsg-sw", - "version": "2.1.0", + "version": "2.1.1", "description": "ReiStandard Active Messaging service worker SDK — three-axis push schema (content / reasoning / tool_request / error) with per-kind client postMessage events", "repository": { "type": "git", diff --git a/packages/rei-standard-amsg/sw/src/index.js b/packages/rei-standard-amsg/sw/src/index.js index 9ef3b08..82fc620 100644 --- a/packages/rei-standard-amsg/sw/src/index.js +++ b/packages/rei-standard-amsg/sw/src/index.js @@ -73,6 +73,7 @@ const DEFAULT_MULTIPART_OPTIONS = Object.freeze({ const memoryMultipartPending = new Map(); const memoryMultipartDone = new Map(); const memoryMultipartChunks = new Map(); +const multipartLocks = new Map(); /** * Wire-level message type for SW → client postMessage envelopes. @@ -227,8 +228,8 @@ async function dispatchBusinessPayload(sw, payload, defaults) { if (typeof defaults.onBusinessPayload === 'function') { try { const result = defaults.onBusinessPayload(payload); - if (result instanceof Promise) { - work.push(result.catch(error => { + if (result && typeof result.then === 'function') { + work.push(Promise.resolve(result).catch(error => { console.error('[rei-standard-amsg-sw] onBusinessPayload promise rejected:', error); })); } @@ -352,7 +353,7 @@ function createNotificationFromPayload(payload, defaults) { const title = pushNotification.title || payload.title || - payload.contactName || + (payload.contactName && `来自 ${payload.contactName}`) || 'New notification'; const body = pushNotification.body || payload.body || payload.message || ''; const data = pushNotification.data && typeof pushNotification.data === 'object' @@ -411,14 +412,31 @@ function isMultipartPush(payload) { } async function acceptMultipartChunk(sw, payload, options) { + const normalized = normalizeMultipartChunk(payload, options); + if (!normalized) return null; + + const previous = multipartLocks.get(normalized.id) || Promise.resolve(); + const current = previous + .catch(() => undefined) + .then(() => acceptMultipartChunkInternal(sw, normalized, options)); + + multipartLocks.set(normalized.id, current); + try { + return await current; + } finally { + if (multipartLocks.get(normalized.id) === current) { + multipartLocks.delete(normalized.id); + } + } +} + +async function acceptMultipartChunkInternal(sw, normalized, options) { // State machine: // 1. Validate the transport envelope and reject expired chunks before storage. // 2. Drop already-completed multipart ids using the short-lived done marker. // 3. Expire any stale pending record for this id before accepting a new one. // 4. Store only new chunk indexes, track total received bytes, and wait. // 5. Once all indexes are present, restore original JSON and mark done. - const normalized = normalizeMultipartChunk(payload, options); - if (!normalized) return null; if (normalized.expiresAt <= Date.now()) { await dispatchMultipartExpired(sw, { id: normalized.id, diff --git a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs index 1ee8baf..b9d13bc 100644 --- a/packages/rei-standard-amsg/sw/test/dispatch.test.mjs +++ b/packages/rei-standard-amsg/sw/test/dispatch.test.mjs @@ -143,6 +143,43 @@ test('content push triggers showNotification AND postMessage with CONTENT_RECEIV }); }); +test('onBusinessPayload waits for thenables returned by another promise implementation', async () => { + const { sw, triggerPush } = createSwMock(); + let hookSettled = false; + const thenable = { + then(resolve) { + Promise.resolve().then(() => { + hookSettled = true; + resolve(); + }); + } + }; + installReiSW(sw, { + onBusinessPayload() { + return thenable; + } + }); + + await triggerPush({ ...COMMON, messageKind: 'content', message: 'thenable hook' }); + + assert.equal(hookSettled, true); +}); + +test('content push without title falls back to "来自 {contactName}"', async () => { + const { sw, notifications, triggerPush } = createSwMock(); + installReiSW(sw); + + await triggerPush({ + ...COMMON, + messageKind: 'content', + message: 'No explicit title', + contactName: 'Rei' + }); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].title, '来自 Rei'); +}); + test('content push broadcasts to every controlled client', async () => { const { sw, postedMessages, triggerPush } = createSwMock({ clientCount: 3 }); installReiSW(sw); @@ -291,6 +328,27 @@ test('generic multipart content restores payload before dispatch and notificatio assert.deepEqual(postedMessages[0].message.payload, payload); }); +test('generic multipart serializes concurrent chunks for the same id', async () => { + const { sw, notifications, postedMessages, triggerPush } = createSwMock(); + installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); + + const payload = { + ...COMMON, + messageKind: 'content', + message: 'parallel multipart content '.repeat(30), + title: 'Concurrent Multipart Rei' + }; + const parts = buildMultipartPayloads(payload, { id: 'mp_sw_concurrent', maxChunkBytes: 80 }); + + await Promise.all(parts.map(part => triggerPush(part))); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].title, 'Concurrent Multipart Rei'); + assert.equal(postedMessages.length, 1); + assert.equal(postedMessages[0].message.event, REI_SW_EVENT.CONTENT_RECEIVED); + assert.deepEqual(postedMessages[0].message.payload, payload); +}); + test('generic multipart tool_request restores silently without notification', async () => { const { sw, notifications, postedMessages, triggerPush } = createSwMock(); installReiSW(sw, { multipart: { cleanupIntervalMs: 0 } }); @@ -570,4 +628,3 @@ test('multipart fully received payload with notification.show: "when-hidden" che assert.equal(notifications[0].title, 'Multipart Hidden'); } }); -