@turmind/halo 1.5.3-alpha → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/admin-out/404.html +1 -1
  2. package/admin-out/_next/static/chunks/{133-33aa091fba853037.js → 422-cc74b1fdd3af5415.js} +1 -1
  3. package/admin-out/_next/static/chunks/989-bc743317498d3fbc.js +1 -0
  4. package/admin-out/_next/static/chunks/app/page-0a390059f1914664.js +1 -0
  5. package/admin-out/_next/static/chunks/{webpack-1a264efd965db7ab.js → webpack-edf248e07333566d.js} +1 -1
  6. package/admin-out/_next/static/css/b53542687ebd4c65.css +3 -0
  7. package/admin-out/index.html +1 -1
  8. package/admin-out/index.txt +5 -5
  9. package/bundled-docs/design/agent.md +4 -4
  10. package/bundled-docs/design/agentcore.md +13 -10
  11. package/bundled-docs/design/architecture.md +24 -14
  12. package/bundled-docs/design/command.md +5 -5
  13. package/bundled-docs/design/cron.md +2 -2
  14. package/bundled-docs/design/evolution.md +34 -35
  15. package/bundled-docs/design/express-self.md +6 -4
  16. package/bundled-docs/design/feishu.md +18 -14
  17. package/bundled-docs/design/halo-city.md +5 -4
  18. package/bundled-docs/design/observability.md +9 -9
  19. package/bundled-docs/design/prompt-system.md +12 -12
  20. package/bundled-docs/design/session.md +12 -12
  21. package/bundled-docs/design/slack.md +22 -20
  22. package/bundled-docs/design/storage.md +18 -19
  23. package/bundled-docs/design/telegram.md +26 -8
  24. package/bundled-docs/design/web.md +3 -1
  25. package/bundled-docs/design/wechat.md +8 -6
  26. package/bundled-docs/design/wecom.md +23 -9
  27. package/bundled-docs/design/ws.md +6 -6
  28. package/bundled-docs/dev/acp-adapter.md +8 -8
  29. package/bundled-docs/dev/add-channel.md +12 -9
  30. package/bundled-docs/dev/add-model-provider.md +31 -20
  31. package/bundled-docs/dev/api.md +83 -57
  32. package/bundled-docs/dev/deploy.md +8 -6
  33. package/bundled-docs/dev/env.md +16 -11
  34. package/bundled-docs/dev/logging.md +15 -29
  35. package/bundled-docs/dev/previews.md +20 -11
  36. package/bundled-docs/dev/tools.md +24 -12
  37. package/bundled-docs/guide/README.md +1 -0
  38. package/bundled-docs/guide/agents.md +19 -53
  39. package/bundled-docs/guide/channels/README.md +7 -4
  40. package/bundled-docs/guide/channels/acp.md +3 -1
  41. package/bundled-docs/guide/channels/feishu.md +13 -11
  42. package/bundled-docs/guide/channels/slack.md +12 -10
  43. package/bundled-docs/guide/channels/telegram.md +21 -27
  44. package/bundled-docs/guide/channels/web.md +15 -11
  45. package/bundled-docs/guide/channels/wechat.md +15 -14
  46. package/bundled-docs/guide/channels/wecom.md +7 -2
  47. package/bundled-docs/guide/chat.md +11 -10
  48. package/bundled-docs/guide/cli.md +10 -8
  49. package/bundled-docs/guide/getting-started.md +3 -3
  50. package/bundled-docs/guide/secrets-and-credentials.md +3 -3
  51. package/bundled-docs/guide/sessions.md +3 -3
  52. package/bundled-docs/guide/skills.md +8 -6
  53. package/bundled-docs/guide/testing-agents-and-skills.md +14 -15
  54. package/bundled-docs/guide/workspace.md +27 -15
  55. package/bundled-docs/requirements/agent.md +19 -18
  56. package/bundled-docs/requirements/chat.md +19 -21
  57. package/bundled-docs/requirements/command.md +10 -10
  58. package/bundled-docs/requirements/editor.md +10 -9
  59. package/bundled-docs/requirements/explorer.md +10 -7
  60. package/bundled-docs/requirements/session.md +16 -17
  61. package/bundled-docs/requirements/settings.md +18 -14
  62. package/bundled-docs/requirements/skill.md +4 -4
  63. package/bundled-docs/requirements/source-control.md +4 -3
  64. package/bundled-docs/requirements/terminal.md +13 -13
  65. package/bundled-docs/requirements/workspace.md +19 -13
  66. package/dist/index.js +2038 -3558
  67. package/package.json +1 -1
  68. package/templates/config.yaml +6 -0
  69. package/templates/skills/workspace/share.md +11 -3
  70. package/templates/skills/workspace/stage.py +85 -5
  71. package/admin-out/_next/static/chunks/989-c6c7772db8a50926.js +0 -1
  72. package/admin-out/_next/static/chunks/app/page-5f41b086cd5ad353.js +0 -1
  73. package/admin-out/_next/static/css/8809d33379458514.css +0 -3
  74. /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_buildManifest.js +0 -0
  75. /package/admin-out/_next/static/{Wqi_ugna9MYsiV7ECuawQ → cuVcldfdg1PXevaQMQSzb}/_ssgManifest.js +0 -0
@@ -104,6 +104,8 @@ loop {
104
104
  3. If the text begins with `/`, dispatch a slash command (see below) — if it returns `handled`, stop here
105
105
  4. Hand off to `deliverInbound` ([shared skeleton](telegram.md#shared-inbound-skeleton)), which caches `lastActiveChatId`, resolves-or-creates the session, sends a busy/compacting hint when needed, refreshes the reply route (`wxRoute(fromUserId, context_token)`), attaches the `WechatResponder` listener once, and delivers: clean text to the UI log, `[channel: wechat | user: <id>]\n<text>` to the agent (so it knows how to address the media-send skill)
106
106
 
107
+ **After a restart**, `startAccount` calls `restoreReplyRoutes()` before the loop starts: every user with a stored `contextTokens` entry gets a reply route + listener on their latest existing session (`restoreChannelRoute`, no session created), so a turn that resumes on its own still reaches WeChat.
108
+
107
109
  ## Slash commands
108
110
 
109
111
  Implemented in `handler.ts`'s `handleSlashCommand()`:
@@ -124,12 +126,12 @@ Unknown `/` input falls through to normal message handling.
124
126
  WeChat `sendMessage` is block-send, while LLMs stream. The current strategy is "buffer the turn's reply, flush as one message":
125
127
  - Only `stream` events flagged `final` (the wrap-up text of a turn — see [session.md](session.md#message-queue-and-drain)) accumulate in a single buffer; the filler the model emits before a tool call is dropped (it stays in the web UI)
126
128
  - `complete` → flush the whole buffer as one (or more) messages. The responder flushes on **any** `complete` and does not read its `batchBoundary` flag — so when the root drains a multi-message queue across several merged turns (see [session.md](session.md#message-queue-and-drain)), each round's batch-boundary `complete` ships that round as its own message instead of buffering every round into one blob that lands only at the terminal `complete` (the "8 reports in one lump" bug)
127
- - Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope). When the buffer exceeds it mid-stream, the shared `splitText` (`channels/shared/chunk.ts`) cuts at the nearest paragraph break (or hard-cuts) and dispatches; the under-limit remainder keeps accumulating. Exported so the cron dispatcher obeys the same limit (see [Proactive sending](#proactive-sending-cron))
128
- - Every chunk goes through one serialized send chain per responder (`sendTail`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
129
- - `error` → immediate flush + send a `[错误] …` message; `system` → immediate flush + send a `[系统] …` message
129
+ - Hard cap `WECHAT_TEXT_LIMIT = 3500` **chars** (JS string length, not bytes — the ilink gateway rejects a `sendmessage` body over 16 KB with `ret=-2 "prepare failed"`; 3500 chars is ≤ ~10.5 KB of CJK plus the JSON envelope). WeChat is one of the two channels that set `splitMidStream` (with Telegram): when the buffer exceeds the limit mid-stream, the shared `splitText` (`channels/shared/chunk.ts`) cuts at the nearest paragraph break (or hard-cuts) and dispatches; the under-limit remainder keeps accumulating. Exported so the cron dispatcher obeys the same limit (see [Proactive sending](#proactive-sending-cron))
130
+ - Every chunk goes through one serialized send chain per responder (`sendTail` in the shared `ChunkedResponder`, `channels/shared/responder.ts`, same as Slack / Feishu) so a long reply arrives in order; `close()` returns the drain promise and `InboundBridge` keeps the reply route alive until it settles
131
+ - `error` → immediate flush + send a `❌ …` message; `system` → immediate flush + send a `ℹ️ …` message
130
132
  - Tool calls / tool results / thinking are dropped (detail lives in the web UI)
131
- - Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia`, so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
132
- - Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep; the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
133
+ - Media: the agent emits `MEDIA: <path>` markers, which the responder extracts and turns into actual media uploads. A `full` account may send any readable path (its shell / file tools are unrestricted anyway); other access levels are limited to the account's workspace or the OS temp dir (`/tmp`) — `isMediaPathAllowed(path, workspace, accessLevel)`. A blocked path (e.g. another workspace's files) throws inside `sendMedia` (`assertMediaPathAllowed`), so it rides the responder's `onSendError` path and lands in the session log as a `⚠️ WeChat delivery failed: sendMedia … media path not allowed` notification instead of being dropped silently
134
+ - Send failures are surfaced, never retried: when `sendText` / `sendMedia` rejects (typically the gateway's `ret=-2`), `dispatchChunk` logs it and calls the optional `onSendError(message)` dep (via the `onSendFailed` override); the handler wires that to `sm.appendNotification(sessionId, '⚠️ WeChat delivery failed: …')`, so the session log shows the turn ran but its reply never reached WeChat. `appendNotification` only appends a `notification` row and persists — it emits no event, which matters: a `system` event would loop back into this responder's `case 'system'` and try to send to WeChat again
133
135
 
134
136
  **The responder never captures its recipient.** Both `sendText` and `sendMedia` read `bridge.getRoute(sessionId)` at send time, so a `{fromUserId, contextToken}` refreshed by a later inbound message takes effect immediately. Closing over `fromUserId` at listener-registration time was the A-M2 bug: after a full-access `/session switch` moved a session to another user, the listener kept replying to the *first* one. `contextToken` (WeChat's passive-reply-window credential) is carried over between messages **only while the user is the same** — a new user's route starts without the old token rather than replaying it against the new recipient. `/session switch` additionally calls `bridge.dropListener(oldSid)` before wiring the target so the abandoned session doesn't keep a live responder.
135
137
 
@@ -141,7 +143,7 @@ Plain text + images (base64 attached to the agent message), voice, video, files.
141
143
 
142
144
  Supported: text, images, voice, video, files; slash commands listed above; per-session access level.
143
145
 
144
- Not supported: group chats, typing indicators.
146
+ Not supported: group chats — a platform limit, not a halo filter: the iLink bot is a one-to-one contact and `getupdates` never carries group messages, so the handler has no group path at all; typing indicators.
145
147
 
146
148
  ## References
147
149
 
@@ -33,7 +33,7 @@ One 智能机器人 (= one botId + secret pair) is bound to one workspace. A wor
33
33
 
34
34
  ### Access level
35
35
 
36
- Each account carries `accessLevel: 'full' | 'workspace' | 'readonly' | 'observer'` (default `readonly`):
36
+ Each account carries `accessLevel: 'full' | 'workspace' | 'readonly'` (default `readonly`; the route validates against `CHAT_ACCESS_LEVELS`, so `observer` — web-only — is rejected):
37
37
  - `full` — no restrictions (no sandbox)
38
38
  - `workspace` — tool execution runs inside a bwrap sandbox with the workspace mounted read-write
39
39
  - `readonly` — tool execution runs inside a bwrap sandbox with the workspace mounted read-only; readonly sessions only receive read-only tools
@@ -46,7 +46,7 @@ WeCom bot accounts are stored in the unified channel DB: `~/.halo/secrets/channe
46
46
 
47
47
  WeCom-specific config JSON fields: `botId`, `secret`, `lastActiveChatId`.
48
48
 
49
- - `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`).
49
+ - `lastActiveChatId` is the raw `userid` (single) or `chatid` (group) of the most recent inbound message, written by `rememberLastActiveChat()` in `channels/shared/accounts.ts` (idempotent). Not consumed by cron dispatch (the dispatcher requires an explicit `chatId`); read once at `startAccount` to restore the reply route (see [Reply route after a restart](#reply-route-after-a-restart)).
50
50
  - `secret` is never returned by `GET /api/wecom/accounts` and is not patchable — a fresh POST with the same `botId` rotates it.
51
51
 
52
52
  Inbound media lands under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
@@ -67,7 +67,7 @@ The wecom cron-dispatcher (`channels/wecom/cron-dispatcher.ts`) requires an expl
67
67
 
68
68
  Because the push rides the handler's socket, the dispatcher borrows it through the exported `liveClients: Map<accountId, WSClient>` (set on `authenticated`, cleared on stop / kick / auth exhaustion). No live client → `{ ok: false, error: 'wecom long-connect not active' }` rather than queueing. WeCom additionally refuses pushes to a user / group that has never messaged the bot, and rate-limits each conversation to 30 msgs/min · 1000/hour (replies and pushes combined).
69
69
 
70
- **`MEDIA:` is not implemented for cron** — the dispatcher declares no `supportsMedia`, so `dispatchToTargets` hands it the original text with `MEDIA:` lines intact (the path degrades to visible text, same caveat as telegram / feishu in [cron.md](cron.md#dispatch-model)). The realtime uploader (`sendWecomMedia` in `handler.ts`) is anchored to an inbound `req_id`; wiring cron up means switching it to `sendMediaMessage(chatid, …)` and flipping `supportsMedia: true`.
70
+ **`MEDIA:` is not implemented for cron** — the dispatcher declares no `supportsMedia`, so `dispatchToTargets` hands it the original text with `MEDIA:` lines intact (the path degrades to visible text, same caveat as telegram / feishu in [cron.md](cron.md#dispatch-model)). The realtime uploader (`sendWecomMedia` in `handler.ts`) already falls back to `sendMediaMessage(chatid, …)` for a route without a `req_id` (restored at startup); wiring cron up means calling it with `{ reqId: '', chatId }` and flipping `supportsMedia: true`.
71
71
 
72
72
  ## Modules
73
73
 
@@ -97,6 +97,7 @@ Session-prefix kind `'wecom'` is registered in `channels/shared/session-prefix.t
97
97
  2. Server stores the account, calls `stopAccount` (idempotent) then `startAccount` — the stop must fully `disconnect()` before the new socket subscribes, or the new connection would kick its own predecessor
98
98
  3. `startAccount` builds `new WSClient({ botId, secret, maxReconnectAttempts: -1, maxAuthFailureAttempts: 3, logger })` and calls `connect()` (synchronous; auth happens on `authenticated`). The SDK handles heartbeat (30 s ping), reconnect with exponential back-off (infinite on network drops), and the auth retry budget
99
99
  4. `authenticated` → `liveClients.set(accountId, client)`; `event.disconnected_event` (kicked) or `error` with `WS_AUTH_FAILURE_EXHAUSTED` → client dropped, `liveClients.delete`
100
+ Before `connect()`, `startAccount` restores the reply route from `lastActiveChatId` (see [Reply route after a restart](#reply-route-after-a-restart))
100
101
  5. `stopAccount` = `bridge.closeAll()` (drains queued chunks, then releases reply routes) → `wsClient.disconnect()` → `liveClients.delete` → state removed
101
102
  6. On graceful shutdown the descriptor's `shutdown` calls `stopAll()`
102
103
 
@@ -110,11 +111,23 @@ All frames are JSON `{ cmd, headers: { req_id }, body }`; responses echo `header
110
111
  | `ping` / pong | client → server | SDK heartbeat |
111
112
  | `aibot_msg_callback` | server → client | inbound message → `'message'` event |
112
113
  | `aibot_event_callback` | server → client | events; only `disconnected_event` is handled (kicked). `enter_chat` / `template_card_event` / `feedback_event` are ignored |
113
- | `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` — every reply |
114
- | `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` — cron only |
114
+ | `aibot_respond_msg` | client → server | `replyStream(frame, streamId, content, finish)` — replies with a live `req_id` |
115
+ | `aibot_send_msg` | client → server | `sendMessage(chatid, { msgtype: 'markdown' })` / `sendMediaMessage(chatid, …)` — cron, and replies on a route restored at startup |
115
116
  | `aibot_upload_media_init` / `_chunk` / `_finish` | client → server | `uploadMedia(buffer, { type, filename })` — chunked (≤ 512 KB × ≤ 100), returns `media_id` (valid 3 days) |
116
117
 
117
- **Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId, chatType, chatId }`, refreshed on every inbound message (the route is read lazily at send time, as in slack / feishu).
118
+ **Reply model.** A stream reply is keyed to the callback's `req_id` and carries its own `stream.id`; the same `stream.id` can be re-sent to update the bubble until `finish: true`. Halo does **not** stream: every flushed chunk / hint / command reply is sent as a *new* `stream.id` with `finish: true` in one go — one finished bubble each. This keeps the responder identical to the other channels and sidesteps the 10-minute cap on an open stream (measured from its first frame). Replies must reference the inbound `req_id`, so the reply route stored per session is `{ reqId, chatId }`, refreshed on every inbound message (the route is read lazily at send time, as in slack / feishu). An empty `reqId` (restored route, below) switches `replyText` / `sendWecomMedia` to the proactive push.
119
+
120
+ ### Reply route after a restart
121
+
122
+ Like the other channels, `startAccount` calls `restoreReplyRoute` → `restoreChannelRoute` (`channels/shared/inbound.ts`) before connecting, so a session that resumes on its own after a restart (run-ledger nudge, queued turn, admin-typed message) still reaches WeCom before the user writes again. It reads `lastActiveChatId`, takes the latest existing root session under `wecom_<normalizeWecomId(chatId)>_` (goal-routed, never creates one) and wires route `{ reqId: '', chatId }` + listener. Both single chats and groups restore: the cached raw id maps 1:1 onto the session key, unlike telegram (a group id doesn't name the member) or feishu (a group thread reply needs the inbound message id).
123
+
124
+ What is **not** restored, and why:
125
+
126
+ - **Passive replies.** The inbound `req_id` lived only in the old process's memory (and is only valid ~10 minutes anyway), so the restored route has none. Text goes out as `aibot_send_msg` markdown, media as `uploadMedia` + `sendMediaMessage`. That is a push, not a stream reply: the bubble isn't attached to the user's message, and the push channel's own rules apply — it is refused for a user / group that has never messaged the bot, and counts against the 30 msgs/min · 1000/hour per-conversation limit.
127
+ - **Only the last active chat.** One `lastActiveChatId` per account → at most one session restored; other users' sessions wait for their next message (same as telegram / slack / feishu).
128
+ - **A connected socket.** The route is wired before `connect()`. A send that fires before the socket is open is rejected by the SDK (`WebSocket not connected`) and logged as `[WeCom] sendText failed`; after a kick / auth exhaustion there is no `wsClient` and the send is skipped — same as for any route. In practice the resumed turn's model call outlasts the ~1 s auth handshake.
129
+
130
+ The next inbound message from that chat replaces the restored route with a fresh `req_id`, and replies go back to passive streaming.
118
131
 
119
132
  ## Message handling flow
120
133
 
@@ -147,18 +160,19 @@ Stream `content` is capped at 20480 **bytes**. The responder splits at 5000 **ch
147
160
  - `MEDIA:<path>` markers intercepted and sent via native media upload
148
161
  - Sub-agent events dropped (visible in web UI only)
149
162
 
150
- Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out — identical to the feishu adapter, see [feishu.md](feishu.md#event-coalescing-feishuresponder) for the history.
163
+ Chunk sends are serialized per responder (`sendTail` promise chain, audit A-L3) and `close()` returns the drain promise so `InboundBridge` keeps the reply route alive until the last chunk is out — both come from the shared `ChunkedResponder` (`channels/shared/responder.ts`) that the feishu adapter also extends, see [feishu.md](feishu.md#event-coalescing-feishuresponder) for the history.
151
164
 
152
165
  ## Media support
153
166
 
154
167
  **Inbound:** text, voice (as transcript), images (vision + saved), mixed (text + images), files, videos — single chat only for everything but text. Saved under `<workspace>/.halo/assets/wecom/inbound/<accountId>/<date>/`.
155
168
 
156
- **Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload):
169
+ **Outbound routing** (`sendWecomMedia`; for non-`full` accounts the path must be under the workspace or the OS temp dir — `isMediaPathAllowed(path, workspace, accessLevel)`, `full` accounts may send any readable path; a blocked path surfaces as the same `t('handler.upload_failed')` reply as a failed upload — `sendMediaOrReport` in `channels/shared/media.ts`):
157
170
  - `.png` / `.jpg` / `.jpeg` / `.gif` → `uploadMedia(type: 'image')` → `replyMedia('image')`
158
171
  - `.mp4` → `uploadMedia(type: 'video')` → `replyMedia('video')`
159
172
  - everything else (`.webp` / `.bmp` / `.mov` / documents / audio) → `uploadMedia(type: 'file')` → `replyMedia('file')` — WeCom's `image` accepts png/jpg/gif only and `video` mp4 only
160
173
  - > 20 MB → refused; the user sees `t('handler.upload_failed')` (`⚠️ 文件上传失败:name — error`)
161
174
  - Voice is never sent: WeCom expects AMR, which the agent doesn't produce
175
+ - On a route restored at startup (no `req_id` yet) every `replyMedia(...)` above becomes `sendMediaMessage(chatId, type, mediaId)` — the proactive push
162
176
 
163
177
  ## Similarities vs Feishu and Telegram
164
178
 
@@ -212,6 +226,6 @@ Not supported: streaming partial replies into WeCom (each reply is one finished
212
226
  - Event dispatch: `packages/server/src/channels/wecom/handler.ts:handleInbound()`
213
227
  - Media upload: `packages/server/src/channels/wecom/handler.ts:sendWecomMedia()`
214
228
  - Cron dispatch: `packages/server/src/channels/wecom/cron-dispatcher.ts:dispatch()`
215
- - Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder`
229
+ - Event coalescing: `packages/server/src/channels/wecom/event-adapter.ts:WecomResponder` (extends `channels/shared/responder.ts:ChunkedResponder`)
216
230
  - REST routes: `packages/server/src/routes/wecom.ts`
217
231
  - Tests: `packages/server/test/wecom-inbound.test.ts` (pure helpers), `wecom-responder.test.ts` (chunking / ordering / markdown pass-through)
@@ -48,7 +48,7 @@ Liveness probes bound how long a zombie lives, but a chat sent *into* the zombie
48
48
 
49
49
  - **Client** ([ws-client.ts](../../../packages/admin/src/shared/ws-client.ts)): every chat carries a client-generated `clientMsgId` and enters a pending-ack table (capacity 100 — past that the oldest entry is failed visibly) before transmission. Transmission is gated on freshness: if inbound has been silent > 30 s, the socket is torn down first and the chat rides the reconnect instead of being trusted to a suspect link. **5 s without `chat:ack`** → force-close, and the `onopen` flush retransmits every unacked entry in insertion order. After **3 transmit attempts** the client stops tearing the link down on that chat's behalf (rapid network flaps would otherwise turn one chat into endless connection churn) but the entry stays pending and still rides every flush. A **2 min wall-clock deadline** per entry is the final arbiter: every chat terminates either in an ack or in a visible `_chat_send_failed` (red "send failed" badge on the bubble + its empty placeholder converged) — no infinite lurking.
50
50
  - **Server** ([handler.ts](../../../packages/server/src/ws/handler.ts)): ack semantics = *the message is in the session log*. Each `handleChat` branch calls `rememberChat` **synchronously right after `appendUserMessage`** (no `await` in between — an await there opens a double-append window against a racing resend), then replies `{type:'chat:ack', clientMsgId}` once enqueueing is done. Resends are deduped against a handler-scoped FIFO of the last 500 acked ids (handler-scoped because a resend arrives on a *new* connection): a duplicate is re-acked without re-appending. At-least-once resend + server dedup ≈ exactly-once in the session log.
51
- - **Indicator**: the connection light is tri-state (`fresh` / `stale` / `down`, [use-websocket.ts](../../../packages/admin/src/shared/use-websocket.ts)) driven by inbound-traffic age rather than last-known socket state — `stale` (amber) at > 45 s of silence (3× probe interval), `down` (red) on close. A zombie link can no longer sit green.
51
+ - **Indicator**: the connection indicator (the network segment of the activity bar's quick-toggles entry) is tri-state (`fresh` / `stale` / `down`, [use-websocket.ts](../../../packages/admin/src/shared/use-websocket.ts)) driven by inbound-traffic age rather than last-known socket state — `stale` (amber) at > 45 s of silence (3× probe interval), `down` (red) on close. A zombie link can no longer sit green.
52
52
  - **Known boundary (accepted)**: the dedup FIFO is in-memory. If the server restarts inside the narrow window between "ack sent but lost in flight" and the client's resend, the new process re-appends the message and the agent runs it twice. The overlap is seconds-rare, and persisting the table would put a synchronous disk write on the chat hot path — not worth it unless observed in practice.
53
53
 
54
54
  ## Client → Server
@@ -86,7 +86,7 @@ Optional `chat` fields:
86
86
 
87
87
  ## Server → Client
88
88
 
89
- Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-processor.ts#L53) `sendWsNotification` switch.
89
+ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts) `sendWsNotification` switch.
90
90
 
91
91
  ### Agent event → WS message mapping
92
92
 
@@ -102,7 +102,7 @@ Source: [event-processor.ts:53-129](../../../packages/server/src/ws/event-proces
102
102
  | `tool_result` | `agent:tool_result` | result, toolUseId, agentName, taskId, durationMs, sessionId |
103
103
  | `followup_start` / `queued_message` | `chat:followup` | agentName, sessionId |
104
104
  | `usage` (no taskId) | `chat:usage` | contextTokens, outputTokens, turnId, modelId, usage, sessionId |
105
- | `complete` | `chat:complete` | sessionId |
105
+ | `complete` | `chat:complete` | sessionId, batchBoundary (only between drained queued turns; the admin closes bubbles but doesn't ring the finish bell) |
106
106
  | `context` | `agent:context` | agentName, systemPrompt, taskId, sessionId |
107
107
  | `system` | `chat:system` | text, taskId, agentName, sessionId |
108
108
  | `error` | `error` | error, agentName, taskId, sessionId |
@@ -118,7 +118,7 @@ Every provider streams (Bedrock, the Anthropic-Messages providers anthropic / mi
118
118
 
119
119
  `chat:system` producers (`session-manager.ts`'s `stop` event handling): a `max_tokens` stop emits `⚠️ [<agent>] Response truncated: output token limit reached.`; a `refusal` stop (Anthropic `stop_reason: "refusal"`, HTTP 200 — the model declined, not an error) emits `⚠️ [<agent>] Model declined to respond (<category>): <explanation> — …` suggesting `/new` (see [session.md](session.md#resilient-execution-loop)).
120
120
 
121
- Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all) and `complete.batchBoundary`. The admin renders every streamed block, so none is needed on the wire.
121
+ Server-internal flags on `AgentSessionEvent` that are **not** carried into the WS frame: `stream.final` (marks the turn's wrap-up text vs. pre-tool filler — consumed by channel responders and the cli, see [session.md](session.md#message-queue-and-drain)), `stream.streamed` / `thinking.streamed` (the text already went out as deltas — decides whether the whole event is forwarded at all). The admin renders every streamed block, so none is needed on the wire.
122
122
 
123
123
  ### Other Server → Client messages
124
124
 
@@ -231,11 +231,11 @@ Client reconnects and sends `subscribe`:
231
231
 
232
232
  ### Double-subscribe guard
233
233
 
234
- Inside the subscribe handler, `messageLog.length === 0` is a precondition for loading from file — so if reattach has already populated the log, subsequent subscribes can't overwrite it with stale file data. Prevents two consecutive subscribes from losing state.
234
+ The reattach path reads the cached UIState (`getCachedUIState`), never disk. A plain subscribe goes through `getSessionView` → `prepareForView`, which keeps the cached state while the session is live in this process (self-driven, or active work in its tree) and re-seeds from disk otherwise — so a repeat subscribe can't overwrite in-flight state with stale file data.
235
235
 
236
236
  ### Client-side reconnect reconciliation
237
237
 
238
- The chat stream reconciles itself (above), but several admin panels keep state in sync purely from incremental `file:changed` / bus deltas — events emitted while the socket was down are lost forever, leaving them stale until an unrelated event arrives, or indefinitely when none does. Every such subscriber pairs its delta subscription with `onWsReconnect(wsClient, <its existing refetch>)` ([ws-reconnect.ts](../../../packages/admin/src/shared/ws-reconnect.ts)) — a reconnect re-reads from the server instead of trusting the gap. Current consumers: file tree (`use-file-tree.ts` → `loadFileTree`, silent replace), editor open tabs (`editor-panel.tsx` → `refreshActiveTab`), git decorations, Source Control panel + history graph, skills sidebar, agent-management list, agent session-chat panel, and the session-list bus (`state-handlers.ts`).
238
+ The chat stream reconciles itself (above), but several admin panels keep state in sync purely from incremental `file:changed` / bus deltas — events emitted while the socket was down are lost forever, leaving them stale until an unrelated event arrives, or indefinitely when none does. Every such subscriber pairs its delta subscription with `onWsReconnect(wsClient, <its existing refetch>)` ([ws-reconnect.ts](../../../packages/admin/src/shared/ws-reconnect.ts)) — a reconnect re-reads from the server instead of trusting the gap. Current consumers: file tree (`use-file-tree.ts` → `loadFileTree`, silent replace), editor open tabs (`editor-panel.tsx` → `refreshActiveTab`), git decorations, Source Control panel + history graph, skills sidebar, agent-management list, agent session-chat panel, the session-list bus and the preview-extension list (`state-handlers.ts`).
239
239
 
240
240
  `everConnected` seeds from `client.connected`, which makes "reconnect" mean the same thing for both mount timings: a panel mounting on an already-open socket treats the next `_connected` as a reconnect, while a page-load mount does **not** fire on its first `_connected` (the subscriber's own mount fetch already covered it — firing would be a pure double-pull).
241
241
 
@@ -34,7 +34,7 @@ Claude Code on a developer's laptop wants to talk to a halo agent running in an
34
34
  --port 9527 \
35
35
  --token <web-token-from-step-1> \
36
36
  --workspace /abs/path/on/server \
37
- --agent-id default # optional; falls back to 'default'
37
+ --agent-id default # optional; omitted = the workspace's entry agent
38
38
  ```
39
39
 
40
40
  3. The adapter writes JSON-RPC frames to stdout (1 message per line) and reads stdin the same way. Stderr is reserved for human-readable diagnostics — do not parse.
@@ -80,7 +80,7 @@ To remove a binding: `/acp remove` (deletes the skill directory and points out t
80
80
  | `--scheme` | no | URL scheme — `http` or `https` (default `http`). Use `https` when the server sits behind a TLS reverse proxy. Invalid values are rejected at the adapter boundary. |
81
81
  | `--token` | yes | Web-channel token from admin UI. `full` access required for multi-workspace use. |
82
82
  | `--workspace` | yes | Absolute server-side path for the workspace this adapter drives. |
83
- | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Default: `default`. Alias: `--agent` (matches the main CLI's `--agent`). |
83
+ | `--agent-id` | no | Halo agent profile to use when ACP `session/new` creates a new halo session. Must be a non-internal, non-disabled agent (else `session/new` fails with `agent not available`). Default: the workspace's entry agent — the highest-`priority` non-disabled, non-internal agent, i.e. `default` out of the box. Alias: `--agent` (matches the main CLI's `--agent`). |
84
84
  | `--header` | no | Extra HTTP header on every upstream request, `"Name: value"` (like `curl -H`). Repeatable. For auth that sits **in front of** the halo server — see "Upstream auth" below. |
85
85
 
86
86
  One adapter process binds to one workspace. To drive multiple workspaces concurrently from the same token, run multiple adapter processes — see "Multi-workspace" below.
@@ -89,7 +89,7 @@ One adapter process binds to one workspace. To drive multiple workspaces concurr
89
89
 
90
90
  `--token` authenticates the adapter to *halo itself* (it becomes the `x-token` header on every web-channel call). It does **not** cover a proxy sitting in front of the server — an SSO/session-cookie gateway, Cloudflare Access, ALB OIDC, nginx basic-auth. Those reject the request before halo ever sees the token.
91
91
 
92
- `--header` is the generic escape hatch: it forwards arbitrary headers on every request the adapter makes (`/api/web/chat`, `/history`, `/stop`), exactly like `curl -H`. The adapter deliberately knows nothing about any specific gateway — you supply whatever that layer wants:
92
+ `--header` is the generic escape hatch: it forwards arbitrary headers on every request the adapter makes (`/api/web/sessions`, `/chat`, `/history`, `/stop`), exactly like `curl -H`. The adapter deliberately knows nothing about any specific gateway — you supply whatever that layer wants:
93
93
 
94
94
  ```sh
95
95
  # session-cookie gateway
@@ -137,8 +137,8 @@ This keeps the adapter stateless on disk — losing the in-memory map on restart
137
137
  | `session` | (latched internally) | First-frame echo of the resolved sessionId. Adapter records it; not surfaced. |
138
138
  | `stream` (assistant text) | `agent_message_chunk` | Forwarded as `content: { type: 'text', text }`. |
139
139
  | `thinking` | `agent_thought_chunk` | Same shape as message chunk. |
140
- | `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`. `kind: 'other'` because halo doesn't categorize tools. |
141
- | `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call` — halo's `tool_result` event doesn't carry the tool name, but it's emitted in lockstep with its call. |
140
+ | `tool_call` | `tool_call` (status: in_progress) | Adapter mints a stable `toolCallId`; the halo `toolInput` goes out as `rawInput`. `kind: 'other'` because halo doesn't categorize tools. |
141
+ | `tool_result` | `tool_call_update` (status: completed) | Pairs by *order* with the most recent `tool_call` (the frame's `toolName`, when present, must match it). A result with no matching call is sent as a self-contained `tool_call` + `tool_call_update`. The web channel truncates `result` to 500 chars. |
142
142
  | `file` | `agent_message_chunk: [file: …]` | The file lives on the server; without reverse fs we can only point at it textually. |
143
143
  | `error` | `agent_message_chunk: [error] …` then end | Ends the prompt response with `stopReason: 'end_turn'` (we treat agent errors as a normal end-of-turn for protocol purposes). |
144
144
  | `queued` | `agent_message_chunk: [queued — session busy]` | Halo queues messages when the session is busy. Adapter ends the response. |
@@ -150,7 +150,7 @@ This keeps the adapter stateless on disk — losing the in-memory map on restart
150
150
 
151
151
  A single web-channel token in halo is bound to one workspace at the database level. The adapter works around this by using the per-request `workspace` + `sessionId` overrides on `/api/web/*`:
152
152
 
153
- - `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept `workspace=<path>` and `sessionId=<id>` (query params, headers `x-workspace` / `x-session-id`, or POST body fields).
153
+ - `/api/web/chat`, `/api/web/stop`, `/api/web/history`, `/api/web/subscribe` accept `workspace=<path>` and `sessionId=<id>` (query params, headers `x-workspace` / `x-session-id`, or POST body fields); `/api/web/sessions` accepts `workspace` and `agentId` the same way.
154
154
  - Server gates the workspace override on `accessLevel === 'full'` — readonly / workspace tokens cannot escape their account-bound workspace.
155
155
  - The adapter sends both fields on every request, so concurrent adapters on the same token but different `--workspace` flags don't step on each other.
156
156
 
@@ -172,7 +172,7 @@ For now: if the user wants the agent to see a Mac-side file, they paste it into
172
172
  Code is in `packages/acp-adapter/`:
173
173
 
174
174
  - `src/jsonrpc.ts` — minimal newline-delimited JSON-RPC 2.0 peer over stdio. No LSP-style Content-Length framing — ACP uses one JSON object per line.
175
- - `src/halo-client.ts` — wraps `POST /api/web/chat` (SSE), `POST /api/web/stop`. Parses `data: <json>\n\n` frames into JS objects.
175
+ - `src/halo-client.ts` — wraps `POST /api/web/sessions`, `POST /api/web/chat` (SSE), `POST /api/web/stop` and `GET /api/web/history`. Parses `data: <json>\n\n` frames into JS objects.
176
176
  - `src/adapter.ts` — registers the ACP method handlers, owns the per-session state (`Map<sessionId, { workspace, lastToolCall, promptAbort }>` — `sessionId` is shared with halo), translates SSE events to `session/update` notifications.
177
177
  - `src/index.ts` — CLI argv parsing, wires stdin/stdout to a `JsonRpcConnection`, instantiates the adapter.
178
178
 
@@ -180,7 +180,7 @@ CLI integration is in `@turmind/halo-cli`'s `index.ts` `cmd === 'acp'` branch
180
180
 
181
181
  ## Testing
182
182
 
183
- No automated suite yet — verification is by manual smoke. The cases below cover the protocol surface and the realistic end-to-end shape (Claude Code → ACP adapter → halo server → remote agent). When you change adapter / web-channel / settings code, walk this list.
183
+ `packages/acp-adapter/test/` holds vitest unit tests for the JSON-RPC framing and SSE parsing (`pnpm --filter @turmind/halo-acp-adapter test`); the adapter ↔ server flow has no automated suite and is verified by manual smoke. The cases below cover the protocol surface and the realistic end-to-end shape (Claude Code → ACP adapter → halo server → remote agent). When you change adapter / web-channel / settings code, walk this list.
184
184
 
185
185
  ### Setup
186
186
 
@@ -28,7 +28,7 @@ What you write yourself:
28
28
 
29
29
  ## Reference implementation
30
30
 
31
- Whole directory: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/) (~1900 lines across 9 files).
31
+ Whole directory: [packages/server/src/channels/wechat/](../../../packages/server/src/channels/wechat/) (~1800 lines across 10 files).
32
32
 
33
33
  The pattern that works:
34
34
 
@@ -88,6 +88,7 @@ All channel accounts live in a single unified table: `~/.halo/secrets/channels/c
88
88
  2. Create `accounts.ts` as a thin adapter over `channels/shared/accounts.ts`:
89
89
  - Map `ChannelAccount.config` JSON → your typed interface
90
90
  - Pass `channelType = 'slack'` to all shared DAL calls
91
+ - Write through `insertChannelAccount(db, 'slack', data, config)` / `updateChannelAccount(db, id, patch, configKeys)` — the shared helpers own the row columns + config-JSON merge; your file only lists its config keys
91
92
 
92
93
  Reference adapters: [channels/telegram/accounts.ts](../../../packages/server/src/channels/telegram/accounts.ts), [channels/web/accounts.ts](../../../packages/server/src/channels/web/accounts.ts).
93
94
 
@@ -101,11 +102,11 @@ Inbound: Slack pushes events via **HTTPS webhooks** (Events API) or **WebSocket*
101
102
 
102
103
  ### 4. Write the main handler
103
104
 
104
- `handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`. WeChat shape at [packages/server/src/channels/wechat/handler.ts:73-157](../../../packages/server/src/channels/wechat/handler.ts#L73-L157).
105
+ `handler.ts`: exports `startSlackChannel(deps: { registry, db })` returning `{ startAccount, stopAccount, stopAll }`. WeChat shape: `startWechatChannel` in [packages/server/src/channels/wechat/handler.ts](../../../packages/server/src/channels/wechat/handler.ts).
105
106
 
106
107
  For webhook-style channels (Slack Events API), you don't need a `runAccountLoop` — instead expose an HTTP route that validates the signature, deserializes the event, and calls `handleInbound`. For Socket Mode, you **do** have a loop (the WebSocket reconnect loop).
107
108
 
108
- Inside `handleInbound`, **don't hand-roll the session/listener plumbing** — the get-or-create, access folding, busy hint, responder registration and agent-input assembly all live in [channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (`InboundBridge` + `deliverInbound` + `dispatchChannelCommand`). All four channels go through it; the four hand-written copies it replaced are how audit findings A-M2 (responder locked onto the first user in a chat) and A-M5 (skill-command turns ran with no listener attached) happened. Your handler parses the platform message and supplies a **reply route**:
109
+ Inside `handleInbound`, **don't hand-roll the session/listener plumbing** — the get-or-create, access folding, busy hint, responder registration and agent-input assembly all live in [channels/shared/inbound.ts](../../../packages/server/src/channels/shared/inbound.ts) (`InboundBridge` + `deliverInbound` + `dispatchChannelCommand`). All five IM channels go through it; the hand-written copies it replaced are how audit findings A-M2 (responder locked onto the first user in a chat) and A-M5 (skill-command turns ran with no listener attached) happened. Your handler parses the platform message and supplies a **reply route**:
109
110
 
110
111
  ```ts
111
112
  // One bridge per running account, owned by the account state so
@@ -146,14 +147,14 @@ async function handleInbound({ registry, db, account, event, bridge, activeOverr
146
147
 
147
148
  ### 5. Write the event adapter
148
149
 
149
- `event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) — all four existing responders do; don't write a private splitter. Slack has a 40k char limit but users dislike huge messages, so it splits at 35k. Serialize your sends through one promise chain per responder (see wechat's `sendTail`) and return it from `close()` so the bridge keeps the route alive until the tail has gone out.
150
+ `event-adapter.ts`: buffers streamed text and sends whole messages. WeChat's flushes on either a 3500-char hard ceiling (`WECHAT_TEXT_LIMIT`, the ilink gateway rejects >16 KB) or a `complete` event; see [packages/server/src/channels/wechat/event-adapter.ts](../../../packages/server/src/channels/wechat/event-adapter.ts). Use the shared `splitText(text, limit)` from `channels/shared/chunk.ts` for the cut (paragraph boundary, then hard-cut) — every existing responder does; don't write a private splitter. Slack has a 40k char limit but users dislike huge messages, so it splits at 35k. Block-oriented channels extend `ChunkedResponder` from `channels/shared/responder.ts` (all five existing channels do), passing `{ limit, logTag, format?, splitMidStream? }`: it buffers `final` text, splits with `splitText` (mid-stream too with `splitMidStream`, as telegram / wechat do), serializes sends through one `sendTail` promise chain and returns it from `close()` so the bridge keeps the route alive until the tail has gone out. Override `onSendFailed` only for a real channel quirk (wechat's `onSendError`).
150
151
 
151
152
  Its send primitives take **no destination argument** — they read `bridge.getRoute(sessionId)` on each send (see step 4). A responder that captured a chat/user id at construction time is the A-M2 bug.
152
153
 
153
154
  **What to forward, what to drop**:
154
155
  - Drop `tool_call` / `tool_result` / `thinking` events — they're chatter the user doesn't want in IM
155
156
  - Drop events where `event.taskId` is set (those are sub-agent events; the root agent's text is enough)
156
- - Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `[error]` prefix; `complete` flushes the remaining buffer
157
+ - Forward `stream` text **only when `event.final` is set** — that's the turn's closing reply; the filler the model emits before a tool call ("let me check…") is chatter in IM too. `error` goes out immediately with a `❌ ` prefix (`system` with `ℹ️ `); `complete` flushes the remaining buffer
157
158
  - Forward `system` if the platform can render it (e.g. Slack ephemeral messages)
158
159
 
159
160
  ### 6. Surface channel context to skills
@@ -235,7 +236,7 @@ export function createSlackRoutes(deps: { db, channel }) {
235
236
  }
236
237
  ```
237
238
 
238
- Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts).
239
+ Pattern: [packages/server/src/routes/wechat.ts](../../../packages/server/src/routes/wechat.ts). Shared CRUD pieces live in [packages/server/src/routes/channel-accounts.ts](../../../packages/server/src/routes/channel-accounts.ts): `accountListFields` (common GET row, no credentials), `accountBodyError` (accessLevel + workspacePath checks) and `accountPatchFromBody` (row part of a PATCH); credentials, required fields, id derivation and restart policy stay in your route file.
239
240
 
240
241
  ### 8. Write the server descriptor + register
241
242
 
@@ -275,7 +276,7 @@ That's all the server wiring. `index.ts` calls `bootChannels(app, defaultChannel
275
276
 
276
277
  To let users manage Slack accounts from the web UI:
277
278
 
278
- 1. Build the React component (mirror [packages/admin/src/features/wechat/wechat-settings.tsx](../../../packages/admin/src/features/wechat/wechat-settings.tsx)). Place it at `packages/admin/src/features/slack/slack-settings.tsx`.
279
+ 1. Build the settings page at `packages/admin/src/features/slack/slack-settings.tsx` on the shared pieces in `packages/admin/src/features/channels/`: `useChannelAccounts` + `ChannelAccountsPage` + `ChannelAccountRow` ([account-list.tsx](../../../packages/admin/src/features/channels/account-list.tsx)) for the list, header and inline edit, and `ChannelAddDialog` ([account-form.tsx](../../../packages/admin/src/features/channels/account-form.tsx)) for the add dialog. The page keeps only what's channel-specific: the account type, the row's name / detail line, the credential inputs, and the `api.slack.*` calls. Mirror [packages/admin/src/features/wecom/wecom-settings.tsx](../../../packages/admin/src/features/wecom/wecom-settings.tsx). The shared parts read i18n keys from the channel's own namespace (`slack.title`, `slack.add`, `slack.addTitle`, `slack.addBtn`, `slack.confirmDelete`, …). The full key list is in the header comments of the two files.
279
280
  2. Create `packages/admin/src/features/slack/descriptor.ts`:
280
281
  ```ts
281
282
  import { Hash } from 'lucide-react' // or whatever icon fits
@@ -345,7 +346,7 @@ Channel-specific commands (e.g. WeChat's `/qr`) go in a fallback switch after `d
345
346
 
346
347
  ### Compact / busy states
347
348
 
348
- Handled for you inside `deliverInbound`: it calls `busyHint(sm, sessionId, lang)` ([channels/shared/busy-hint.ts](../../../packages/server/src/channels/shared/busy-hint.ts)) and sends the result through your `sendHint` callback. The hint is a **hint only** — the message is always delivered, because `sendUserMessage` queues compacting/busy sessions itself. (The four inlined copies once carried an extra `return` in the compacting branch, so messages sent mid-compact were answered with a hint and then silently dropped.)
349
+ Handled for you inside `deliverInbound`: it calls `busyHint(sm, sessionId, lang)` ([channels/shared/busy-hint.ts](../../../packages/server/src/channels/shared/busy-hint.ts)) and sends the result through your `sendHint` callback. The hint is a **hint only** — the message is always delivered, because `sendUserMessage` queues compacting/busy sessions itself. (The inlined per-channel copies once carried an extra `return` in the compacting branch, so messages sent mid-compact were answered with a hint and then silently dropped.)
349
350
 
350
351
  ### Single-instance lock
351
352
 
@@ -361,13 +362,15 @@ At handler startup, `resolveAccountWorkspace(account)` checks the path exists on
361
362
 
362
363
  Use the shared helper `saveInboundMedia({ workspacePath, accountId, channel: 'slack', buffer, kind: 'image', mimeType })` — [packages/server/src/channels/shared/media-store.ts](../../../packages/server/src/channels/shared/media-store.ts). It saves under `<ws>/.halo/assets/slack/inbound/<accountId>/<date>/` and returns the path. Append `[图片已保存: /abs/path]` to the agent's input text and the existing UI code will render a thumbnail.
363
364
 
365
+ Outbound `MEDIA: <path>` markers go through `sendMediaOrReport({ filePath, account, logTag, send, reply })` ([channels/shared/media.ts](../../../packages/server/src/channels/shared/media.ts)): it runs the access-level path check (`assertMediaPathAllowed`), then your `send()` upload, and reports a block or failed upload back to the chat. A responder that already has its own failure channel can call `assertMediaPathAllowed` directly and let the throw ride `onSendError` (WeChat does this).
366
+
364
367
  For images going to the LLM, also pass them as base64 in the `images` arg — `sm.sendUserMessage(sid, text, images)`.
365
368
 
366
369
  ---
367
370
 
368
371
  ## Testing
369
372
 
370
- No automated test framework for channels — manual for now:
373
+ Shared channel plumbing has automated coverage under `packages/server/test/` (`channel-*.test.ts` for the inbound bridge, responder ordering / `final`-only forwarding, media whitelist, route restore, access-level and workspace validation; plus per-channel files such as `telegram-group-chat.test.ts`, `wecom-inbound.test.ts`, `wecom-responder.test.ts`). The platform round-trip itself is still manual:
371
374
 
372
375
  1. Create a test bot on the platform (development workspace / test server)
373
376
  2. Bind it to a throwaway Halo workspace: `POST /api/slack/accounts`
@@ -19,14 +19,14 @@ Total effort: typically a few hours depending on how weird the upstream API is a
19
19
  | Provider ID | Class | File | Notes |
20
20
  |---|---|---|---|
21
21
  | `aws-bedrock-claude-invoke` | `BedrockAgent` | `bedrock-agent.ts` | Anthropic Messages API via AWS SDK |
22
- | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
22
+ | `kimi` | `KimiAgent` | `kimi-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage arrives in its own `choices: []` chunk), thinking (reasoning_content). Cached tokens via `cachedPromptTokens` (every known usage key — which one Kimi reports isn't pinned). Two API generations, branched on modelId: **K3** — vision (base64 data URLs only, no public-URL input), thinking always-on server-side, sole knob is top-level `reasoning_effort` (enum currently just `"max"`); **K2.6** — no vision, no `reasoning_effort` field at all, only the `thinking:{type:'enabled'|'disabled'}` toggle. Never send the K2.x `thinking` parameter to K3 |
23
23
  | `deepseek` | `DeepSeekAgent` | `deepseek-agent.ts` | OpenAI-compatible, streaming via `fetchChatCompletionStream` (first chunk has `content: null` + `reasoning_content: ''`; the usage chunk still carries `choices`), thinking (reasoning_content). Vision per model: `deepseek-flash` (V4.1-Flash) sees `image_url` data URLs; `deepseek-v4-pro` accepts the block but ignores it (live-probed 2026-09-24 with a 64×64 blue PNG) — registry `image` flag gates it |
24
24
  | `minimax` | `MiniMaxAgent` | `minimax-agent.ts` | Anthropic Messages API (x-api-key), streaming via `fetchAnthropicStream` — note `message_start.usage` is all zeros and the real input / cache counts arrive in `message_delta.usage` (the accumulator takes them from there). Two thinking schemes, branched on registry `thinking.mode`: **M3** (default) — vision, thinking off by default and enabled via `thinking:{type:'adaptive'}` (no levels, no budget_tokens), no `promptCaching` in the manifest on purpose (official docs scope `cache_control` to M2.x only; M3 has passive automatic caching); **M2.7** — no vision, thinking always-on (manual budget_tokens), prompt caching (5m, M2.7 doesn't report cache_creation_input_tokens) |
25
25
  | `qwen` | `QwenAgent` | `qwen-agent.ts` | Aliyun Bailian Anthropic-compatible API, streaming via `fetchAnthropicStream` (SSE frames come as `data:{…}` with no space after the colon — the reader accepts both), thinking enable/disable both honored, prompt caching (5m only), vision on Plus, max no vision |
26
26
  | `hunyuan` | `HunyuanAgent` | `hunyuan-agent.ts` | Tencent Hy3 preview via OpenAI-compatible (tokenhub.tencentmaas.com), streaming via `fetchChatCompletionStream` (`stream_options.include_usage` accepted; usage in its own `choices: []` chunk), reasoning_effort low/high (no_think = omit), automatic prompt caching (cached_tokens), no vision |
27
27
  | `doubao` | `DoubaoAgent` | `doubao-agent.ts` | ByteDance Doubao Seed-2.0 (pro/lite/mini/code) on Volcengine Ark, OpenAI-compatible, streaming via `fetchChatCompletionStream` (usage in its own `choices: []` chunk) + `thinking:{type:'enabled'/'disabled'}` (no `auto`), automatic prompt caching, no vision |
28
28
  | `zhipu` | `ZhipuAgent` | `zhipu-agent.ts` | Zhipu GLM-5.x on open.bigmodel.cn, OpenAI-compatible, streaming via `fetchChatCompletionStream` (tool-call arguments arrive complete in one fragment; usage chunk carries `choices`). Output cap via `max_tokens` only (`max_completion_tokens` is silently ignored). Thinking branched on modelId: **glm-5.3\*** always thinks — always sends `thinking:{type:'enabled',clear_thinking:false}`, `reasoning_effort` only when low/high/max (`disabled` / other efforts 400); other ids (glm-5.2) honour `thinking:{type:'disabled'}`. Vision per model: `glm-5.3-flash` / `glm-5.3-flashx` see `image_url` data URLs; `glm-5.3` / `glm-5.2` are text-only and 400 on any image part (code 1210, classified `multimodal_4xx` so leftover history images degrade). Automatic caching, `prompt_tokens_details.cached_tokens` (included in `prompt_tokens`, subtracted). |
29
- | `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`prompt_tokens_details.cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens` all read) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
29
+ | `openai` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). User fills endpoint + model id; capabilities openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
30
30
  | `anthropic` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. User fills endpoint + model id. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
31
31
  | `mimo-token-plan-china` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just a default endpoint in `model-runtime.ts` (`token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is current; `mimo-v2.5(-pro)` kept in the template until the official 2026-10-21 deprecation, then drop |
32
32
  | `aws-bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6.1 Sol + GPT-6 Sol / Luna + every 5.6 model (404s Astra; GPT-6.1 Sol probed 2026-09-30, us-east-1 only); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra + 5.6 Terra / Luna. Template ships all three as `endpointPresets`, default is now us-east-1 (`openai.gpt-6-sol` default model); Astra agents pick us-west-2 in the form; `extractRegionFromHost` falls back to us-east-1. All GPT-6 variants: 1,050,000 context / 128,000 max output, same effort (none/low/medium/high/xhigh/max) + verbosity surface; input beyond 272K tokens bills at 2x, so cap `context.maxTokens` in agent.yaml unless that's intended. Never surfaces reasoning summaries — `output[].reasoning.summary` stays empty even with `reasoning.summary=detailed` while `reasoning_tokens` counts up (verified live), so no thinking text in the UI is upstream behavior, not a parsing gap. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
@@ -57,7 +57,7 @@ export interface ModelRuntime {
57
57
  }
58
58
  ```
59
59
 
60
- `AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel(signal, onDelta?)` — the provider-specific API call. It always returns a `Promise<ModelCallResult>` containing the complete response; the base class `run()` method yields events per loop iteration. **Streaming is optional and per-provider**: if the upstream API streams, call `onDelta({ type: 'text_delta' | 'thinking_delta', text })` per chunk as it arrives — the loop yields those as `text_delta` / `thinking_delta` events during the call and re-arms the per-call timeout on each (so it becomes an idle timeout) — then still return the whole result. For data that arrives but has nothing to show (ping events, tool-argument fragments, empty reasoning chunks, SSE comment frames) call `onDelta(ACTIVITY_DELTA)` (exported from `agent-loop.ts`; `type: 'activity'`, empty text): it only re-arms the idle timer and never reaches the event stream, so a long tool-call argument or a silent reasoning stretch doesn't trip the idle timeout. The shared readers do this for you — `readSseJson`'s per-chunk callback is wired to it in `fetchAnthropicStream` / `fetchChatCompletionStream` / `readResponsesStream`, and the Bedrock agent reports it once per stream event. A non-streaming provider would simply ignore `onDelta`; today every built-in provider streams — Bedrock, the Anthropic-Messages providers (anthropic / mimo / minimax / qwen), the OpenAI-family (openai / deepseek / kimi / zhipu / doubao / hunyuan) and Mantle (aws-bedrock-mantle / aws-bedrock-openai). The fold from Anthropic stream events into a `ModelCallResult` lives in `anthropic-stream.ts` (`AnthropicStreamAccumulator`); for an HTTP gateway speaking Anthropic Messages, `fetchAnthropicStream({ url, headers, body, signal, onDelta, tag })` in the same file is the whole `callModel` body — it posts with `stream: true`, parses the SSE with the generic `readSseJson` reader in `sse.ts` (also skips the OpenAI `[DONE]` terminator), maps a mid-stream `event: error` frame onto the `[tag] <status> <type>: <message>` error shape `classifyModelError` parses, and re-throws `AbortError` if the body ended at the abort instant. For an OpenAI-compatible `chat/completions` endpoint, `fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag })` in `openai-chat-stream.ts` is the transport half of `callModel` — it posts with `stream: true` + `stream_options: { include_usage: true }`, folds the `chat.completion.chunk` frames (text / `reasoning_content` (or the Ollama `reasoning` alias) appended and reported via `onDelta`; `tool_calls[]` fragments matched by `index`, the first fragment carrying `id` + `function.name`, later ones appending `function.arguments`; `finish_reason` / `usage` last-non-null-wins — the usage chunk arrives with `choices` on deepseek / zhipu and as `choices: []` on kimi / doubao / hunyuan) back into the non-streaming `choices[0].message` + `finishReason` + `usage` shape, and throws today's `[<Tag>Agent] API error <status>: <body>` on non-2xx — the agent's existing parse / usage code runs unchanged on the folded result. Mantle's Responses API needs no accumulator at all: `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` / `response.reasoning_summary_text.delta` live and keeps the `response` object off the terminal `response.completed` / `response.incomplete` frame, which is the full final response in exactly the non-streaming JSON shape — the existing `output[]` / `incomplete_details` / `usage` parse runs on it unchanged; a stream that ends without a terminal frame throws the same `MantleEmptyResponse` marker as the empty-`output[]` glitch so `classifyModelError`'s `empty_response` retry covers both.
60
+ `AgentLoop` handles the tool execution loop (call model → execute tools → loop). You only implement `callModel(signal, onDelta?)` — the provider-specific API call. It always returns a `Promise<ModelCallResult>` containing the complete response; the base class `run()` method yields events per loop iteration. **Streaming is optional and per-provider**: if the upstream API streams, call `onDelta({ type: 'text_delta' | 'thinking_delta', text })` per chunk as it arrives — the loop yields those as `text_delta` / `thinking_delta` events during the call and re-arms the per-call timeout on each (so it becomes an idle timeout) — then still return the whole result. For data that arrives but has nothing to show (ping events, tool-argument fragments, empty reasoning chunks, SSE comment frames) call `onDelta(ACTIVITY_DELTA)` (exported from `agent-loop.ts`; `type: 'activity'`, empty text): it only re-arms the idle timer and never reaches the event stream, so a long tool-call argument or a silent reasoning stretch doesn't trip the idle timeout. The shared readers do this for you — `readSseJson`'s per-chunk callback is wired to it in `fetchAnthropicStream` / `fetchChatCompletionStream` / `readResponsesStream`, and the Bedrock agent reports it once per stream event. A non-streaming provider would simply ignore `onDelta`; today every built-in provider streams — Bedrock, the Anthropic-Messages providers (anthropic / mimo / minimax / qwen), the OpenAI-family (openai / deepseek / kimi / zhipu / doubao / hunyuan) and Mantle (aws-bedrock-mantle / aws-bedrock-openai). The fold from Anthropic stream events into a `ModelCallResult` lives in `anthropic-stream.ts` (`AnthropicStreamAccumulator`); for an HTTP gateway speaking Anthropic Messages, `fetchAnthropicStream({ url, headers, body, signal, onDelta, tag })` in the same file is the whole `callModel` body — it posts with `stream: true`, parses the SSE with the generic `readSseJson` reader in `sse.ts` (also skips the OpenAI `[DONE]` terminator), maps a mid-stream `event: error` frame onto the `[tag] <status> <type>: <message>` error shape `classifyModelError` parses, and re-throws `AbortError` if the body ended at the abort instant. For an OpenAI-compatible `chat/completions` endpoint, `fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag })` in `openai-chat-stream.ts` is the transport half of `callModel` — it posts with `stream: true` + `stream_options: { include_usage: true }`, folds the `chat.completion.chunk` frames (text / `reasoning_content` (or the Ollama `reasoning` alias) appended and reported via `onDelta`; `tool_calls[]` fragments matched by `index`, the first fragment carrying `id` + `function.name`, later ones appending `function.arguments`; `finish_reason` / `usage` last-non-null-wins — the usage chunk arrives with `choices` on deepseek / zhipu and as `choices: []` on kimi / doubao / hunyuan) back into the non-streaming `choices[0].message` + `finishReason` + `usage` shape, and throws today's `[<Tag>Agent] API error <status>: <body>` on non-2xx — `chatCompletionResult(folded, usage, startTime)` in `openai-chat-format.ts` then turns the folded reply into a `ModelCallResult`, with the usage numbers the agent computed itself. Mantle's Responses API needs no accumulator at all: `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` / `response.reasoning_summary_text.delta` live and keeps the `response` object off the terminal `response.completed` / `response.incomplete` frame, which is the full final response in exactly the non-streaming JSON shape — the existing `output[]` / `incomplete_details` / `usage` parse runs on it unchanged; a stream that ends without a terminal frame throws the same `MantleEmptyResponse` marker as the empty-`output[]` glitch so `classifyModelError`'s `empty_response` retry covers both.
61
61
 
62
62
  Two important constraints:
63
63
 
@@ -103,39 +103,48 @@ For providers where the API returns `prompt_tokens` inclusive of cached (e.g. Ki
103
103
  Create `packages/server/src/agents/<provider>-agent.ts`. Extend `AgentLoop` and implement `callModel()`.
104
104
 
105
105
  Reference implementations:
106
- - OpenAI-compatible: [kimi-agent.ts](../../../packages/server/src/agents/kimi-agent.ts), [deepseek-agent.ts](../../../packages/server/src/agents/deepseek-agent.ts)
106
+ - OpenAI-compatible: [kimi-agent.ts](../../../packages/server/src/agents/kimi-agent.ts), [deepseek-agent.ts](../../../packages/server/src/agents/deepseek-agent.ts) — conversion helpers in [openai-chat-format.ts](../../../packages/server/src/agents/openai-chat-format.ts)
107
+ - Anthropic Messages over HTTP: [qwen-agent.ts](../../../packages/server/src/agents/qwen-agent.ts), [minimax-agent.ts](../../../packages/server/src/agents/minimax-agent.ts) — body helpers in [anthropic-request.ts](../../../packages/server/src/agents/anthropic-request.ts)
107
108
  - AWS native: [bedrock-agent.ts](../../../packages/server/src/agents/bedrock-agent.ts)
108
109
 
109
- Rough shape for OpenAI-compatible providers:
110
+ Rough shape for OpenAI-compatible providers — the format conversion is shared (`openai-chat-format.ts`); what the class writes itself is the provider's quirks: the request body (output-cap field, thinking / reasoning knobs), the image mode, the usage mapping and the error tag:
110
111
 
111
112
  ```ts
112
113
  import { AgentLoop } from './agent-loop.js'
113
- import type { ContentBlock, ModelCallResult, ToolDef } from './agent-loop.js'
114
+ import type { ModelCallResult, ModelDelta, ToolDef } from './agent-loop.js'
115
+ import { fetchChatCompletionStream } from './openai-chat-stream.js'
116
+ import { chatCompletionResult, toChatMessages, toChatTools } from './openai-chat-format.js'
114
117
 
115
118
  export class MyProviderAgent extends AgentLoop {
116
119
  constructor(config: MyConfig) { super(config.tools); ... }
117
120
 
118
121
  protected async callModel(signal, onDelta?): Promise<ModelCallResult> {
119
122
  const startTime = Date.now()
120
- const messages = this.buildMessages() // translate AnthropicMessage[] → provider format
121
- const tools = this.buildTools() // translate ToolDef[] → provider format
123
+ // AnthropicMessage[] → chat messages; image mode = the provider's vision support:
124
+ // 'parts' (image_url parts) | 'if-image' (parts only when an image is present) | 'drop' (text only)
125
+ const messages = toChatMessages(this.config.systemPrompt, this.messages, 'parts')
126
+ const tools = toChatTools(this.config.tools) // ToolDef[] → OpenAI function calling format
122
127
 
123
- // call fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag }) from
124
- // openai-chat-stream.ts (see kimi-agent.ts) — it POSTs with stream: true, reports
125
- // text / reasoning chunks through onDelta and returns the folded { message, finishReason, usage, ttftMs }
126
- // Return ModelCallResult with assistantBlocks, stopReason, text, thinking, toolCalls, usage, durationMs, ttftMs
127
- }
128
+ const body = { model, messages, max_tokens /* or max_completion_tokens */, ...(tools.length > 0 ? { tools } : {}) }
129
+ // + this provider's thinking / reasoning fields
130
+
131
+ // POSTs with stream: true, reports text / reasoning chunks through onDelta,
132
+ // returns the folded { message, finishReason, usage, ttftMs }
133
+ const folded = await fetchChatCompletionStream({ url, headers, body, signal, onDelta, tag: 'MyProviderAgent' })
128
134
 
129
- private buildMessages() { ... } // system + user/assistant/tool message conversion
130
- private buildTools() { ... } // OpenAI function calling format
135
+ // Per-provider usage math: which field carries cached tokens, how they're deducted.
136
+ const cached = ...
137
+ return chatCompletionResult(folded, { inputTokens: prompt - cached, outputTokens, cacheReadInputTokens: cached }, startTime)
138
+ }
131
139
  }
132
140
  ```
133
141
 
134
142
  **Things that bite**:
135
143
 
136
- - **Tool results format.** Anthropic stores tool results as `{ role: 'user', content: [{ type: 'tool_result', ... }] }`. OpenAI-compatible APIs need separate `{ role: 'tool', tool_call_id, content }` messages. See `convertToolResults()` in kimi-agent.ts.
137
- - **Thinking/reasoning replay.** If the provider returns `reasoning_content` and requires it in follow-up messages (Kimi, DeepSeek with tool calls), you must store it in `result.assistantBlocks` as a `{ type: 'thinking', thinking: '...' }` block and replay it as `reasoning_content` in `convertAssistantMessage()`.
138
- - **Reasoning field name varies.** OpenAI o-series/DeepSeek emit reasoning in `message.reasoning_content`; Ollama / llama.cpp's OpenAI-compat layer uses `message.reasoning`. `OpenAIAgent` reads both (`reasoning_content ?? reasoning`). A vendor-specific class should read whichever its API uses.
144
+ - **Tool results format.** Anthropic stores tool results as `{ role: 'user', content: [{ type: 'tool_result', ... }] }`. OpenAI-compatible APIs need separate `{ role: 'tool', tool_call_id, content }` messages, and tool-result images ride in a user message right after them. `toChatMessages()` in openai-chat-format.ts does both.
145
+ - **Thinking/reasoning replay.** If the provider returns `reasoning_content` and requires it in follow-up messages (Kimi, DeepSeek with tool calls), it must be stored in `result.assistantBlocks` as a `{ type: 'thinking', thinking: '...' }` block and replayed as `reasoning_content` — `chatCompletionResult()` stores it and `toChatMessages()` replays it.
146
+ - **Reasoning field name varies.** OpenAI o-series/DeepSeek emit reasoning in `message.reasoning_content`; Ollama / llama.cpp's OpenAI-compat layer uses `message.reasoning`. The shared stream accumulator (`ChatCompletionAccumulator` in `openai-chat-stream.ts`) folds both into one reasoning stream (`reasoning_content ?? reasoning`), so every `fetchChatCompletionStream` provider gets it. A vendor-specific class should read whichever its API uses.
147
+ - **Malformed tool arguments.** Parse tool-call arguments with `parseToolInput(json, toolName)` (`tool-input.ts`) — every built-in path does (Anthropic stream accumulator, `chatCompletionResult`, Mantle). It returns `{ input, error? }` and never throws: empty / missing arguments are `{}` with no error; arguments that aren't valid JSON or aren't a plain JSON object (`null`, an array, a scalar) become `{}` plus an `error` and a `[ToolInput]` warn (tool name and the first 200 chars). Pass the error through as `inputError` on the `toolCalls[]` entry (keep `input: {}` in `assistantBlocks`) — the loop then skips that call and returns an `is_error` result asking the model to resend complete JSON, instead of running the tool with `{}`. A `max_tokens` stop never runs any of the reply's tool calls; the loop answers each with an `is_error` result itself.
139
148
  - **Cancellation.** The caller passes `signal`; pass it to `fetch()` directly.
140
149
  - **Max tokens.** Use `config.maxTokens ?? resolveMaxOutputTokens(config.modelId)` for consistency.
141
150
  - **Prompt caching and thinking** are optional per provider. If unsupported, silently ignore.
@@ -336,7 +345,7 @@ myprovider:
336
345
 
337
346
  ### Effort labels are Halo-side only
338
347
 
339
- `effortPresets: [low, medium, high]` is purely a UI affordance — it lives on the form and gets translated to `budget_tokens` (or whatever the provider wants) inside the agent class via an effort→budget table. Don't write `effort=medium` directly to the wire. Provider doesn't know what `medium` means.
348
+ `effortPresets: [low, medium, high]` is purely a UI affordance — it lives on the form and gets translated to `budget_tokens` (or whatever the provider wants) inside the agent class via an effort→budget table (for the Anthropic-format providers, `effortToBudget()` in `anthropic-request.ts`). Don't write `effort=medium` directly to the wire. Provider doesn't know what `medium` means.
340
349
 
341
350
  Probe each preset's actual budget against the model's `maxOutputTokens`. We hit this with MiniMax: `low` (1024 budget) caused `stop=max_tokens` before any thinking completed — left it out of the preset list.
342
351
 
@@ -378,7 +387,7 @@ The `MantleEmptyResponse` retry guard in session-manager is unrelated to this an
378
387
 
379
388
  Keeping it a hard-coded switch is deliberate for now:
380
389
  - Each provider has enough quirks (auth, response format, tool calling conventions, thinking replay rules) that a generic "provider interface with fetch client" would leak abstractions
381
- - Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge; the shared piece is a transport helper function (`fetchChatCompletionStream`, like `fetchAnthropicStream`), not a base class — each agent still owns its body / message conversion / usage mapping
390
+ - Each provider independently extends `AgentLoop` — no shared OpenAI-compatible base class, to avoid coupling when APIs diverge; the shared pieces are helper functions, not a base class — transport (`fetchChatCompletionStream`, `fetchAnthropicStream`) and format (`openai-chat-format.ts`: message / tool / result conversion; `anthropic-request.ts`: system / tools / `cache_control` placement and the thinking-budget math) — each agent still owns its request body, thinking shape, usage mapping and error tag
382
391
  - The Agent management UI reads the manifest directly; a runtime-registered plugin would need to notify the frontend dynamically
383
392
  - One provider lands every few months, not every week
384
393
 
@@ -393,6 +402,8 @@ If / when Halo has 5+ providers and the switch becomes unwieldy, the next step w
393
402
  - Bedrock (Anthropic native): [packages/server/src/agents/bedrock-agent.ts](../../../packages/server/src/agents/bedrock-agent.ts)
394
403
  - Kimi (OpenAI-compatible + vision): [packages/server/src/agents/kimi-agent.ts](../../../packages/server/src/agents/kimi-agent.ts)
395
404
  - DeepSeek (OpenAI-compatible, no vision): [packages/server/src/agents/deepseek-agent.ts](../../../packages/server/src/agents/deepseek-agent.ts)
405
+ - OpenAI-family format helpers: [packages/server/src/agents/openai-chat-format.ts](../../../packages/server/src/agents/openai-chat-format.ts)
406
+ - Anthropic-family body helpers: [packages/server/src/agents/anthropic-request.ts](../../../packages/server/src/agents/anthropic-request.ts)
396
407
  - Manifest examples: [packages/server/templates/models/](../../../packages/server/templates/models/)
397
408
  - Models registry loader: [packages/server/src/config.ts](../../../packages/server/src/config.ts) (`getModelsRegistry`)
398
409
  - Architecture context: [design/architecture.md#modelruntime](../design/architecture.md#modelruntime--llm-interaction-layer-provider-agnostic)