Compare commits

..
Author SHA1 Message Date
Peter Steinberger 186fdf6b06 fix: refine slack uploadV2 payload (#1447) (thanks @jdrhyne) 2026-01-22 23:16:03 +00:00
Jonathan Rhyne 5022950664 docs: add changelog entry for PR #1447 2026-01-22 23:06:49 +00:00
Jonathan Rhyne a685c7f399 fix(slack): remove deprecated filetype field from files.uploadV2
Slack's files.uploadV2 API no longer supports the filetype field and logs
deprecation warnings when it's included. Slack auto-detects the file type
from the file content, so this field is unnecessary.

This removes the warning:
[WARN] web-api:WebClient filetype is no longer a supported field in files.uploadV2.
2026-01-22 23:06:49 +00:00
Peter Steinberger 56339a17cc fix: correct gog auth services example (#1454) (thanks @zerone0x) 2026-01-22 22:51:59 +00:00
Peter Steinberger 567d8e5aa4 Merge pull request #1454 from zerone0x/docs/fix-gog-auth-services-example
docs(gog): fix invalid service name in auth example
2026-01-22 22:50:48 +00:00
Peter Steinberger da3a141c58 refactor: require session state for directive handling 2026-01-22 22:42:46 +00:00
Peter Steinberger c0c8ee217f fix: clarify session_status model-use guidance 2026-01-22 22:42:37 +00:00
Peter Steinberger 411ce7e231 fix: surface concrete ai error details 2026-01-22 22:24:25 +00:00
Peter Steinberger b709898fb3 Merge pull request #1461 from ameno-/fix/node-daemon-run
Fix node daemon command
2026-01-22 22:02:19 +00:00
Peter Steinberger 826013c990 docs: refresh nodes + pairing docs 2026-01-22 22:02:06 +00:00
Peter Steinberger 482fcd2f2c fix: resolve control UI avatar URLs (#1457) (thanks @dlauer) 2026-01-22 21:58:46 +00:00
Peter Steinberger 6c7f224ce1 Merge pull request #1457 from dlauer/fix/avatar-relative-url-validation
fix(ui): allow relative URLs in avatar validation
2026-01-22 21:57:27 +00:00
Peter Steinberger db146837a1 fix: move session-memory changelog entry 2026-01-22 21:55:10 +00:00
Peter Steinberger 1ef2de1276 fix: cover missing session key model switch persist (#1465) (thanks @robbyczgw-cla) 2026-01-22 21:41:05 +00:00
Peter Steinberger 60cbf97079 Merge pull request #1464 from alfranli123/fix/session-memory-suppress-confirmation
fix(session-memory): suppress user-visible confirmation message
2026-01-22 21:40:15 +00:00
Peter Steinberger 13a62d1a6f Merge pull request #1465 from robbyczgw-cla/fix/model-switch-persist-1435
fix: only show model switch success when persist succeeds (fixes #1435)
2026-01-22 21:37:51 +00:00
Peter Steinberger 534f28a78f Merge pull request #1439 from Nicell/fix/bluebubbles-typing-stop
fix(bluebubbles): call stop typing on idle and NO_REPLY
2026-01-22 21:33:49 +00:00
Peter Steinberger 3993c9a3b4 fix: stop BlueBubbles typing on idle/no-reply (#1439) (thanks @Nicell) 2026-01-22 21:33:19 +00:00
Clawd f552820a75 fix(bluebubbles): call stop typing on idle and NO_REPLY
Previously, typing stop was intentionally skipped because the
BlueBubbles Server DELETE endpoint was bugged (called startTyping
instead of stopTyping). Now that the server bug is fixed, we can
properly stop typing indicators.

- onIdle: now calls sendBlueBubblesTyping(false) to stop typing
- finally block: stops typing when no message sent (NO_REPLY case)
2026-01-22 21:20:35 +00:00
Al 773dad256e fix(session-memory): suppress user-visible confirmation message
The session-memory hook saves session context to memory files when /new is run,
which is useful internal housekeeping. However, the confirmation message that
was displayed to users (showing the file path) leaked implementation details.

This change removes the user-visible message while keeping the console.log
for debugging purposes. The hook continues to save session context silently.
2026-01-22 15:22:20 -05:00
Dave Lauer ffca65d15f fix(gateway): resolve local avatars to URL in HTML injection and RPC
The frontend fix alone wasn't enough because:
1. serveIndexHtml() was injecting the raw avatar filename into HTML
2. agent.identity.get RPC was returning raw filename, overwriting the
   HTML-injected value

Now both paths resolve local file avatars (*.png, *.jpg, etc.) to the
/avatar/{agentId} endpoint URL.
2026-01-22 15:16:31 -05:00
Ameno Osman 654b6a943b fix(node): use node run for node daemon 2026-01-22 11:15:51 -08:00
Dave Lauer 9d09a7879c fix(ui): allow relative URLs in avatar validation
The isAvatarUrl check only accepted http://, https://, or data: URLs,
but the /avatar/{agentId} endpoint returns relative paths like /avatar/main.
This caused local file avatars to display as text instead of images.

Fixes avatar display for locally configured avatar files.
2026-01-22 12:09:27 -05:00
zerone0xandClaude ba824a4b2d docs(gog): fix invalid service name in auth example
Replace invalid "docs" service with the correct "tasks,people" services
in the setup example. The gog CLI does not have a "docs" service - docs
commands (export/cat) use Drive authentication instead.

Fixes #1433

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:45:20 +08:00
58 changed files with 524 additions and 284 deletions

No files matched your search

+6 -1
View File
@@ -2,10 +2,15 @@
Docs: https://docs.clawd.bot
## 2026.1.22
## 2026.1.22 (unreleased)
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447) Thanks @jdrhyne.
- Agents: surface concrete API error details instead of generic AI service errors.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
## 2026.1.21-2
+3 -2
View File
@@ -5,7 +5,7 @@ read_when:
---
# Gateway architecture
Last updated: 2026-01-19
Last updated: 2026-01-22
## Overview
@@ -34,7 +34,8 @@ Last updated: 2026-01-19
### Nodes (macOS / iOS / Android / headless)
- Connect to the **same WS server** with `role: node`.
- Pair with the Gateway to receive a token.
- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and
approval lives in the device pairing store.
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
Protocol details:
+3 -3
View File
@@ -52,10 +52,10 @@ Instances list, `client.mode === "cli"` is **not** turned into a presence entry.
Clients can send richer periodic beacons via the `system-event` method. The mac
app uses this to report host name, IP, and `lastInputSeconds`.
### 4) Node bridge beacons
### 4) Node connects (role: node)
When a node bridge connection authenticates, the Gateway emits a presence entry
for that node and refreshes it periodically so it doesn’t expire.
When a node connects over the Gateway WebSocket with `role: node`, the Gateway
upserts a presence entry for that node (same flow as other WS clients).
## Merge + dedupe rules (why `instanceId` matters)
+4
View File
@@ -11,6 +11,10 @@ In Gateway-owned pairing, the **Gateway** is the source of truth for which nodes
are allowed to join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests.
**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
`node.pair.*` is a separate pairing store and does **not** gate the WS handshake.
Only clients that explicitly call `node.pair.*` use this flow.
## Concepts
- **Pending request**: a node asked to join; requires approval.
+16 -12
View File
@@ -8,9 +8,11 @@ read_when:
# Nodes
A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Bridge protocol details: [Bridge protocol](/gateway/bridge-protocol).
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; for older node clients only).
macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
Notes:
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
@@ -18,21 +20,23 @@ Notes:
## Pairing + status
Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway
creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
Quick CLI:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes reject <requestId>
clawdbot devices list
clawdbot devices approve <requestId>
clawdbot devices reject <requestId>
clawdbot nodes status
clawdbot nodes describe --node <idOrNameOrIp>
clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
```
Notes:
- `nodes rename` stores a display name override in the gateway pairing store.
- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
- `node.pair.*` (CLI: `clawdbot nodes pending/approve/reject`) is a separate gateway-owned
node pairing store; it does **not** gate the WS `connect` handshake.
## Remote node host (system.run)
@@ -275,7 +279,7 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
WebSocket and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
@@ -292,9 +296,9 @@ Notes:
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
## Mac node mode
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`.
+12 -10
View File
@@ -45,27 +45,29 @@ Stored under `~/.clawdbot/credentials/`:
Treat these as sensitive (they gate access to your assistant).
## 2) Node pairing (iOS/Android nodes joining the gateway)
## 2) Node device pairing (iOS/Android/macOS/headless nodes)
Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
creates a device pairing request that must be approved.
### Approve a node
### Approve a node device
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot devices list
clawdbot devices approve <requestId>
clawdbot devices reject <requestId>
```
### Where the state lives
Stored under `~/.clawdbot/nodes/`:
Stored under `~/.clawdbot/devices/`:
- `pending.json` (short-lived; pending requests expire)
- `paired.json` (paired nodes + tokens)
- `paired.json` (paired devices + tokens)
### Details
### Notes
Full protocol + design notes: [Gateway pairing](/gateway/pairing)
- The legacy `node.pair.*` API (CLI: `clawdbot nodes pending/approve`) is a
separate gateway-owned pairing store. WS nodes still require device pairing.
## Related docs
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
@@ -1563,6 +1563,100 @@ describe("BlueBubbles webhook monitor", () => {
expect.any(Object),
);
});
it("stops typing on idle", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
const account = createMockAccount();
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.onReplyStart?.();
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
await params.dispatcherOptions.onIdle?.();
});
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
false,
expect.any(Object),
);
});
it("stops typing when no reply is sent", async () => {
const { sendBlueBubblesTyping } = await import("./chat.js");
vi.mocked(sendBlueBubblesTyping).mockClear();
const account = createMockAccount();
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
false,
expect.any(Object),
);
});
});
describe("outbound message ids", () => {
+18 -3
View File
@@ -1713,8 +1713,17 @@ async function processMessage(
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
}
},
onIdle: () => {
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
onIdle: async () => {
if (!chatGuidForActions) return;
if (!baseUrl || !password) return;
try {
await sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
});
} catch (err) {
logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
}
},
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -1754,7 +1763,13 @@ async function processMessage(
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
// Stop typing indicator when no message was sent (e.g., NO_REPLY)
sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
}).catch((err) => {
logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
});
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21
### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.21",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.21-2",
"version": "2026.1.22",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once)
- `gog auth credentials /path/to/client_secret.json`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
- `gog auth list`
Common commands
@@ -43,8 +43,6 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
);
expect(formatAssistantErrorText(msg)).toBe(
"The AI service returned an error. Please try again.",
);
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
});
});
@@ -17,4 +17,10 @@ describe("formatRawAssistantErrorForUi", () => {
it("renders a generic unknown error message when raw is empty", () => {
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
});
it("formats plain HTTP status lines", () => {
expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error",
);
});
});
@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
it("sanitizes HTTP status errors with error hints", () => {
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
"The AI service returned an error. Please try again.",
"HTTP 500: Internal Server Error",
);
});
it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
});
});
+12 -4
View File
@@ -201,6 +201,14 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "LLM request failed with an unknown error.";
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
if (httpMatch) {
const rest = httpMatch[2].trim();
if (!rest.startsWith("{")) {
return `HTTP ${httpMatch[1]}: ${rest}`;
}
}
const info = parseApiErrorInfo(trimmed);
if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
@@ -261,8 +269,8 @@ export function formatAssistantErrorText(
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
if (isRawApiErrorPayload(raw)) {
return "The AI service returned an error. Please try again.";
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
return formatRawAssistantErrorForUi(raw);
}
// Never return raw unhandled errors - log for debugging but return safe message
@@ -293,7 +301,7 @@ export function sanitizeUserFacingText(text: string): string {
}
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return "The AI service returned an error. Please try again.";
return formatRawAssistantErrorForUi(trimmed);
}
if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -303,7 +311,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}
return "The AI service returned an error. Please try again.";
return formatRawAssistantErrorForUi(trimmed);
}
return stripped;
@@ -6,6 +6,7 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import {
formatAssistantErrorText,
formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint,
isRawApiErrorPayload,
normalizeTextForComparison,
@@ -64,6 +65,12 @@ export function buildEmbeddedRunPayloads(params: {
const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage)
: null;
const formattedRawErrorMessage = rawErrorMessage
? formatRawAssistantErrorForUi(rawErrorMessage)
: null;
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
? normalizeTextForComparison(formattedRawErrorMessage)
: null;
const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage)
: null;
@@ -116,10 +123,15 @@ export function buildEmbeddedRunPayloads(params: {
if (trimmed === genericErrorText) return true;
}
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) return true;
}
if (normalizedFormattedRawErrorMessage) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
}
if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
+1 -1
View File
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};
+1 -1
View File
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
@@ -77,100 +77,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).not.toContain("failed");
});
it("shows error message when sessionEntry is missing", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionStore = {};
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry: undefined, // Missing!
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("failed");
expect(result?.text).toContain("session state unavailable");
});
it("shows error message when sessionStore is missing", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore: undefined, // Missing!
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("failed");
expect(result?.text).toContain("session state unavailable");
});
it("shows error message when sessionKey is missing", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: undefined, // Missing!
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("failed");
expect(result?.text).toContain("session state unavailable");
});
it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = {
+97 -105
View File
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
@@ -288,115 +288,111 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
let didPersistModel = false;
if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel =
currentElevatedLevel ??
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
const prevElevatedLevel =
currentElevatedLevel ??
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (modelSelection) {
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (modelSelection) {
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
didPersistModel = true;
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
});
}
const parts: string[] = [];
if (directives.hasThinkDirective && directives.thinkLevel) {
@@ -449,7 +445,7 @@ export async function handleDirectiveOnly(params: {
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (modelSelection && didPersistModel) {
if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push(
@@ -460,10 +456,6 @@ export async function handleDirectiveOnly(params: {
if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`);
}
} else if (modelSelection && !didPersistModel) {
parts.push(
`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
);
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string;
agentDir: string;
agentCfg: AgentDefaults;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
+2 -2
View File
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string;
agentCfg: AgentDefaults;
sessionCtx: TemplateContext;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
+1 -1
View File
@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
const args = ["node", "run", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId);
+5 -3
View File
@@ -13,7 +13,9 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(
DEFAULT_ASSISTANT_IDENTITY.avatar,
);
});
it("keeps short text avatars", () => {
@@ -25,7 +27,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
});
it("keeps path avatars", () => {
@@ -37,6 +39,6 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantAvatarUrl } from "./control-ui.js";
describe("resolveAssistantAvatarUrl", () => {
it("keeps remote and data URLs", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "https://example.com/avatar.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("https://example.com/avatar.png");
expect(
resolveAssistantAvatarUrl({
avatar: "data:image/png;base64,abc",
agentId: "main",
basePath: "/ui",
}),
).toBe("data:image/png;base64,abc");
});
it("prefixes basePath for /avatar endpoints", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "/ui/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("maps local avatar paths to the avatar endpoint", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/me.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/profile",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("keeps short text avatars", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "PS",
agentId: "main",
basePath: "/ui",
}),
).toBe("PS");
});
});
+40 -2
View File
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
function buildAvatarUrl(basePath: string, agentId: string): string {
export function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
}
@@ -206,11 +206,49 @@ interface ServeIndexHtmlOpts {
agentId?: string;
}
function looksLikeLocalAvatarPath(value: string): boolean {
if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
export function resolveAssistantAvatarUrl(params: {
avatar?: string | null;
agentId?: string | null;
basePath?: string;
}): string | undefined {
const avatar = params.avatar?.trim();
if (!avatar) return undefined;
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
const basePath = normalizeControlUiBasePath(params.basePath);
const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
return `${basePath}${avatar}`;
}
if (avatar.startsWith(baseAvatarPrefix)) return avatar;
if (!params.agentId) return avatar;
if (looksLikeLocalAvatarPath(avatar)) {
return buildAvatarUrl(basePath, params.agentId);
}
return avatar;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
const resolvedAgentId =
typeof (identity as { agentId?: string }).agentId === "string"
? (identity as { agentId?: string }).agentId
: agentId;
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8");
@@ -218,7 +256,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, {
basePath,
assistantName: identity.name,
assistantAvatar: identity.avatar,
assistantAvatar: avatarValue,
}),
);
}
+8 -1
View File
@@ -38,6 +38,7 @@ import {
import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js";
import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -407,7 +408,13 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId });
respond(true, identity, undefined);
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: identity.agentId,
basePath: cfg.gateway?.controlUi?.basePath,
}) ?? identity.avatar;
respond(true, { ...identity, avatar: avatarValue }, undefined);
},
"agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) {
+2 -4
View File
@@ -160,11 +160,9 @@ const saveSessionToMemory: HookHandler = async (event) => {
await fs.writeFile(memoryFilePath, entry, "utf-8");
console.log("[session-memory] Memory file written successfully");
// Send confirmation message to user with filename
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
const relPath = memoryFilePath.replace(os.homedir(), "~");
const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`;
event.messages.push(confirmMsg);
console.log("[session-memory] Confirmation message queued:", confirmMsg);
console.log(`[session-memory] Session context saved to ${relPath}`);
} catch (err) {
console.error(
"[session-memory] Failed to save session memory:",
+1 -2
View File
@@ -88,13 +88,12 @@ async function uploadSlackFile(params: {
threadTs?: string;
maxBytes?: number;
}): Promise<string> {
const { buffer, contentType, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes);
const { buffer, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes);
const basePayload = {
channel_id: params.channelId,
file: buffer,
filename: fileName,
...(params.caption ? { initial_comment: params.caption } : {}),
...(contentType ? { filetype: contentType } : {}),
};
const payload: FilesUploadV2Arguments = params.threadTs
? { ...basePayload, thread_ts: params.threadTs }
@@ -0,0 +1,52 @@
import type { WebClient } from "@slack/web-api";
import { describe, expect, it, vi } from "vitest";
import { loadConfig } from "../config/config.js";
import { loadWebMedia } from "../web/media.js";
import { sendMessageSlack } from "./send.js";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn(),
}));
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn(),
}));
const loadConfigMock = vi.mocked(loadConfig);
const loadWebMediaMock = vi.mocked(loadWebMedia);
describe("slack send", () => {
it("omits filetype in files.uploadV2 payload", async () => {
loadConfigMock.mockReturnValue({ channels: { slack: {} } } as never);
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("data"),
contentType: "image/png",
fileName: "test.png",
kind: "image",
});
const uploadV2 = vi.fn().mockResolvedValue({ files: [{ id: "F123" }] });
const postMessage = vi.fn().mockResolvedValue({ ts: "123.456" });
const client = {
files: { uploadV2 },
chat: { postMessage },
} as unknown as WebClient;
const result = await sendMessageSlack("channel:C123", "hello", {
mediaUrl: "https://example.com/test.png",
token: "xoxb-test",
client,
});
expect(uploadV2).toHaveBeenCalledTimes(1);
const payload = uploadV2.mock.calls[0]?.[0] as Record<string, unknown>;
expect(payload).toMatchObject({
channel_id: "C123",
filename: "test.png",
initial_comment: "hello",
});
expect("filetype" in payload).toBe(false);
expect(result).toEqual({ messageId: "F123", channelId: "C123" });
});
});
+2 -1
View File
@@ -158,7 +158,8 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
/^data:image\//i.test(value)
/^data:image\//i.test(value) ||
/^\//.test(value) // Relative paths from avatar endpoint
);
}