Compare commits

..
57 changed files with 221 additions and 585 deletions

No files matched your search

+1 -9
View File
@@ -2,19 +2,11 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 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.
- 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 ## 2026.1.21-2
### Fixes ### Fixes
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui - Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123.
## 2026.1.21 ## 2026.1.21
+2 -3
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Gateway architecture # Gateway architecture
Last updated: 2026-01-22 Last updated: 2026-01-19
## Overview ## Overview
@@ -34,8 +34,7 @@ Last updated: 2026-01-22
### Nodes (macOS / iOS / Android / headless) ### Nodes (macOS / iOS / Android / headless)
- Connect to the **same WS server** with `role: node`. - Connect to the **same WS server** with `role: node`.
- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and - Pair with the Gateway to receive a token.
approval lives in the device pairing store.
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`. - Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
Protocol details: 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 Clients can send richer periodic beacons via the `system-event` method. The mac
app uses this to report host name, IP, and `lastInputSeconds`. app uses this to report host name, IP, and `lastInputSeconds`.
### 4) Node connects (role: node) ### 4) Node bridge beacons
When a node connects over the Gateway WebSocket with `role: node`, the Gateway When a node bridge connection authenticates, the Gateway emits a presence entry
upserts a presence entry for that node (same flow as other WS clients). for that node and refreshes it periodically so it doesn’t expire.
## Merge + dedupe rules (why `instanceId` matters) ## Merge + dedupe rules (why `instanceId` matters)
-4
View File
@@ -11,10 +11,6 @@ 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 are allowed to join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests. 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 ## Concepts
- **Pending request**: a node asked to join; requires approval. - **Pending request**: a node asked to join; requires approval.
+12 -16
View File
@@ -8,11 +8,9 @@ read_when:
# Nodes # Nodes
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). 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).
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 bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
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: Notes:
- Nodes are **peripherals**, not gateways. They don’t run the gateway service. - Nodes are **peripherals**, not gateways. They don’t run the gateway service.
@@ -20,23 +18,21 @@ Notes:
## Pairing + status ## Pairing + status
**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
Quick CLI: Quick CLI:
```bash ```bash
clawdbot devices list clawdbot nodes pending
clawdbot devices approve <requestId> clawdbot nodes approve <requestId>
clawdbot devices reject <requestId> clawdbot nodes reject <requestId>
clawdbot nodes status clawdbot nodes status
clawdbot nodes describe --node <idOrNameOrIp> clawdbot nodes describe --node <idOrNameOrIp>
clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
``` ```
Notes: Notes:
- `nodes status` marks a node as **paired** when its device pairing role includes `node`. - `nodes rename` stores a display name override in the gateway pairing store.
- `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) ## Remote node host (system.run)
@@ -279,7 +275,7 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
## Headless node host (cross-platform) ## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
WebSocket and exposes `system.run` / `system.which`. This is useful on Linux/Windows bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server. or for running a minimal node alongside a server.
Start it: Start it:
@@ -296,9 +292,9 @@ Notes:
- On macOS, the headless node host prefers the companion app exec host when reachable and falls - 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 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. the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS. - Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode ## Mac node mode
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac). - 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 Gateway port and connects to `localhost`. - In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
+10 -12
View File
@@ -45,29 +45,27 @@ Stored under `~/.clawdbot/credentials/`:
Treat these as sensitive (they gate access to your assistant). Treat these as sensitive (they gate access to your assistant).
## 2) Node device pairing (iOS/Android/macOS/headless nodes) ## 2) Node pairing (iOS/Android nodes joining the gateway)
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
creates a device pairing request that must be approved. The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
### Approve a node device ### Approve a node
```bash ```bash
clawdbot devices list clawdbot nodes pending
clawdbot devices approve <requestId> clawdbot nodes approve <requestId>
clawdbot devices reject <requestId>
``` ```
### Where the state lives ### Where the state lives
Stored under `~/.clawdbot/devices/`: Stored under `~/.clawdbot/nodes/`:
- `pending.json` (short-lived; pending requests expire) - `pending.json` (short-lived; pending requests expire)
- `paired.json` (paired devices + tokens) - `paired.json` (paired nodes + tokens)
### Notes ### Details
- The legacy `node.pair.*` API (CLI: `clawdbot nodes pending/approve`) is a Full protocol + design notes: [Gateway pairing](/gateway/pairing)
separate gateway-owned pairing store. WS nodes still require device pairing.
## Related docs ## Related docs
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/bluebubbles", "name": "@clawdbot/bluebubbles",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot BlueBubbles channel plugin", "description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": { "clawdbot": {
@@ -1563,100 +1563,6 @@ describe("BlueBubbles webhook monitor", () => {
expect.any(Object), 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", () => { describe("outbound message ids", () => {
+3 -18
View File
@@ -1713,17 +1713,8 @@ async function processMessage(
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`); runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
} }
}, },
onIdle: async () => { onIdle: () => {
if (!chatGuidForActions) return; // BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
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) => { onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`); runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -1763,13 +1754,7 @@ async function processMessage(
}); });
} }
if (chatGuidForActions && baseUrl && password && !sentMessage) { if (chatGuidForActions && baseUrl && password && !sentMessage) {
// Stop typing indicator when no message was sent (e.g., NO_REPLY) // BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
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", "name": "@clawdbot/copilot-proxy",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Copilot Proxy provider plugin", "description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/diagnostics-otel", "name": "@clawdbot/diagnostics-otel",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter", "description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/discord", "name": "@clawdbot/discord",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Discord channel plugin", "description": "Clawdbot Discord channel plugin",
"clawdbot": { "clawdbot": {
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-antigravity-auth", "name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin", "description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": { "clawdbot": {
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/google-gemini-cli-auth", "name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin", "description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/imessage", "name": "@clawdbot/imessage",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot iMessage channel plugin", "description": "Clawdbot iMessage channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/lobster", "name": "@clawdbot/lobster",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21 ## 2026.1.21
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/matrix", "name": "@clawdbot/matrix",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Matrix channel plugin", "description": "Clawdbot Matrix channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-core", "name": "@clawdbot/memory-core",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot core memory search plugin", "description": "Clawdbot core memory search plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/memory-lancedb", "name": "@clawdbot/memory-lancedb",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": { "dependencies": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21 ## 2026.1.21
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/msteams", "name": "@clawdbot/msteams",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Microsoft Teams channel plugin", "description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/nextcloud-talk", "name": "@clawdbot/nextcloud-talk",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin", "description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21 ## 2026.1.21
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/nostr", "name": "@clawdbot/nostr",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs", "description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/signal", "name": "@clawdbot/signal",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Signal channel plugin", "description": "Clawdbot Signal channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/slack", "name": "@clawdbot/slack",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Slack channel plugin", "description": "Clawdbot Slack channel plugin",
"clawdbot": { "clawdbot": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/telegram", "name": "@clawdbot/telegram",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Telegram channel plugin", "description": "Clawdbot Telegram channel plugin",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21 ## 2026.1.21
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/voice-call", "name": "@clawdbot/voice-call",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot voice-call plugin", "description": "Clawdbot voice-call plugin",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/whatsapp", "name": "@clawdbot/whatsapp",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot WhatsApp channel plugin", "description": "Clawdbot WhatsApp channel plugin",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21 ## 2026.1.21
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalo", "name": "@clawdbot/zalo",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Zalo channel plugin", "description": "Clawdbot Zalo channel plugin",
"clawdbot": { "clawdbot": {
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.22
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.21 ## 2026.1.21
### Changes ### Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/zalouser", "name": "@clawdbot/zalouser",
"version": "2026.1.22", "version": "2026.1.21",
"type": "module", "type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli", "description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "clawdbot", "name": "clawdbot",
"version": "2026.1.22", "version": "2026.1.21-2",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "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) Setup (once)
- `gog auth credentials /path/to/client_secret.json` - `gog auth credentials /path/to/client_secret.json`
- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets` - `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
- `gog auth list` - `gog auth list`
Common commands Common commands
@@ -43,6 +43,8 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError( const msg = makeAssistantError(
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}', '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
); );
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded"); expect(formatAssistantErrorText(msg)).toBe(
"The AI service returned an error. Please try again.",
);
}); });
}); });
@@ -17,10 +17,4 @@ describe("formatRawAssistantErrorForUi", () => {
it("renders a generic unknown error message when raw is empty", () => { it("renders a generic unknown error message when raw is empty", () => {
expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); 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", () => { it("sanitizes HTTP status errors with error hints", () => {
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe( expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error", "The AI service returned an error. Please try again.",
); );
}); });
it("sanitizes raw API error payloads", () => { it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded"); expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
}); });
}); });
+4 -12
View File
@@ -201,14 +201,6 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim(); const trimmed = (raw ?? "").trim();
if (!trimmed) return "LLM request failed with an unknown error."; 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); const info = parseApiErrorInfo(trimmed);
if (info?.message) { if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
@@ -269,8 +261,8 @@ export function formatAssistantErrorText(
return "The AI service is temporarily overloaded. Please try again in a moment."; return "The AI service is temporarily overloaded. Please try again in a moment.";
} }
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) { if (isRawApiErrorPayload(raw)) {
return formatRawAssistantErrorForUi(raw); return "The AI service returned an error. Please try again.";
} }
// Never return raw unhandled errors - log for debugging but return safe message // Never return raw unhandled errors - log for debugging but return safe message
@@ -301,7 +293,7 @@ export function sanitizeUserFacingText(text: string): string {
} }
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) { if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return formatRawAssistantErrorForUi(trimmed); return "The AI service returned an error. Please try again.";
} }
if (ERROR_PREFIX_RE.test(trimmed)) { if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -311,7 +303,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) { if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out."; return "LLM request timed out.";
} }
return formatRawAssistantErrorForUi(trimmed); return "The AI service returned an error. Please try again.";
} }
return stripped; return stripped;
@@ -6,7 +6,6 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../../../config/config.js"; import type { ClawdbotConfig } from "../../../config/config.js";
import { import {
formatAssistantErrorText, formatAssistantErrorText,
formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint, getApiErrorPayloadFingerprint,
isRawApiErrorPayload, isRawApiErrorPayload,
normalizeTextForComparison, normalizeTextForComparison,
@@ -65,12 +64,6 @@ export function buildEmbeddedRunPayloads(params: {
const rawErrorFingerprint = rawErrorMessage const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage) ? getApiErrorPayloadFingerprint(rawErrorMessage)
: null; : null;
const formattedRawErrorMessage = rawErrorMessage
? formatRawAssistantErrorForUi(rawErrorMessage)
: null;
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
? normalizeTextForComparison(formattedRawErrorMessage)
: null;
const normalizedRawErrorText = rawErrorMessage const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage) ? normalizeTextForComparison(rawErrorMessage)
: null; : null;
@@ -123,15 +116,10 @@ export function buildEmbeddedRunPayloads(params: {
if (trimmed === genericErrorText) return true; if (trimmed === genericErrorText) return true;
} }
if (rawErrorMessage && trimmed === rawErrorMessage) return true; if (rawErrorMessage && trimmed === rawErrorMessage) return true;
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
if (normalizedRawErrorText) { if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed); const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) return true; if (normalized && normalized === normalizedRawErrorText) return true;
} }
if (normalizedFormattedRawErrorMessage) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
}
if (rawErrorFingerprint) { if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed); const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) return true; 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_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session", sessions_spawn: "Spawn a sub-agent session",
session_status: session_status:
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override", "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
image: "Analyze an image with the configured image model", image: "Analyze an image with the configured image model",
}; };
+1 -1
View File
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status", label: "Session Status",
name: "session_status", name: "session_status",
description: description:
"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).", "Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
parameters: SessionStatusToolSchema, parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentId?: string; agentId?: string;
isGroup: boolean; isGroup: boolean;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
@@ -1,113 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { handleDirectiveOnly } from "./directive-handling.impl.js";
// Mock dependencies
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => ({})),
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveSessionAgentId: vi.fn(() => "main"),
}));
vi.mock("../../agents/sandbox.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
}));
vi.mock("../../config/sessions.js", () => ({
updateSessionStore: vi.fn(async () => {}),
}));
vi.mock("../../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
function baseAliasIndex(): ModelAliasIndex {
return { byAlias: new Map(), byKey: new Map() };
}
function baseConfig(): ClawdbotConfig {
return {
commands: { text: true },
agents: { defaults: {} },
} as unknown as ClawdbotConfig;
}
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
const allowedModelCatalog = [
{ provider: "anthropic", id: "claude-opus-4-5" },
{ provider: "openai", id: "gpt-4o" },
];
it("shows success message when session state is available", 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: "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("Model set to");
expect(result?.text).toContain("openai/gpt-4o");
expect(result?.text).not.toContain("failed");
});
it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world");
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: "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}`,
});
// No model directive = no model message
expect(result?.text ?? "").not.toContain("Model set to");
expect(result?.text ?? "").not.toContain("failed");
});
});
+98 -96
View File
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: { export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
directives: InlineDirectives; directives: InlineDirectives;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
elevatedEnabled: boolean; elevatedEnabled: boolean;
@@ -288,110 +288,112 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" && nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel); !supportsXHighThinking(resolvedProvider, resolvedModel);
const prevElevatedLevel = if (sessionEntry && sessionStore && sessionKey) {
currentElevatedLevel ?? const prevElevatedLevel =
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ?? currentElevatedLevel ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel)); (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
const prevReasoningLevel = (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off"; const prevReasoningLevel =
let elevatedChanged = currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
directives.hasElevatedDirective && let elevatedChanged =
directives.elevatedLevel !== undefined && directives.hasElevatedDirective &&
elevatedEnabled && directives.elevatedLevel !== undefined &&
elevatedAllowed; elevatedEnabled &&
let reasoningChanged = elevatedAllowed;
directives.hasReasoningDirective && directives.reasoningLevel !== undefined; let reasoningChanged =
if (directives.hasThinkDirective && directives.thinkLevel) { directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel; if (directives.hasThinkDirective && directives.thinkLevel) {
else sessionEntry.thinkingLevel = 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 (directives.execSecurity) { if (shouldDowngradeXHigh) {
sessionEntry.execSecurity = directives.execSecurity; sessionEntry.thinkingLevel = "high";
} }
if (directives.execAsk) { if (directives.hasVerboseDirective && directives.verboseLevel) {
sessionEntry.execAsk = directives.execAsk; applyVerboseOverride(sessionEntry, directives.verboseLevel);
} }
if (directives.execNode) { if (directives.hasReasoningDirective && directives.reasoningLevel) {
sessionEntry.execNode = directives.execNode; if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
} }
} if (directives.hasElevatedDirective && directives.elevatedLevel) {
if (modelSelection) { // Unlike other toggles, elevated defaults can be "on".
applyModelOverrideToSessionEntry({ // Persist "off" explicitly so `/elevated off` actually overrides defaults.
entry: sessionEntry, sessionEntry.elevatedLevel = directives.elevatedLevel;
selection: modelSelection, elevatedChanged =
profileOverride, elevatedChanged ||
}); (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
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") { if (directives.hasExecDirective && directives.hasExecOptions) {
sessionEntry.queueCap = directives.cap; 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 (directives.dropPolicy) { if (modelSelection) {
sessionEntry.queueDrop = directives.dropPolicy; applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
} }
} if (directives.hasQueueDirective && directives.queueReset) {
sessionEntry.updatedAt = Date.now(); delete sessionEntry.queueMode;
sessionStore[sessionKey] = sessionEntry; delete sessionEntry.queueDebounceMs;
if (storePath) { delete sessionEntry.queueCap;
await updateSessionStore(storePath, (store) => { delete sessionEntry.queueDrop;
store[sessionKey] = sessionEntry; } else if (directives.hasQueueDirective) {
}); if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
} if (typeof directives.debounceMs === "number") {
if (modelSelection) { sessionEntry.queueDebounceMs = directives.debounceMs;
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; }
if (nextLabel !== initialModelLabel) { if (typeof directives.cap === "number") {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), { 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), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey, sessionKey,
contextKey: `model:${nextLabel}`, contextKey: "mode:reasoning",
}); });
} }
}
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[] = []; const parts: string[] = [];
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string; agentId: string;
agentDir: string; agentDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"]; sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
+2 -2
View File
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string; workspaceDir: string;
agentCfg: AgentDefaults; agentCfg: AgentDefaults;
sessionCtx: TemplateContext; sessionCtx: TemplateContext;
sessionEntry: SessionEntry; sessionEntry?: SessionEntry;
sessionStore: Record<string, SessionEntry>; sessionStore?: Record<string, SessionEntry>;
sessionKey: string; sessionKey: string;
storePath?: string; storePath?: string;
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"]; sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
+1 -1
View File
@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
runtime?: GatewayRuntimePreference; runtime?: GatewayRuntimePreference;
nodePath?: string; nodePath?: string;
}): Promise<GatewayProgramArgs> { }): Promise<GatewayProgramArgs> {
const args = ["node", "run", "--host", params.host, "--port", String(params.port)]; const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls"); if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint); if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId); if (params.nodeId) args.push("--node-id", params.nodeId);
+3 -5
View File
@@ -13,9 +13,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
}, },
}; };
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe( expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
DEFAULT_ASSISTANT_IDENTITY.avatar,
);
}); });
it("keeps short text avatars", () => { it("keeps short text avatars", () => {
@@ -27,7 +25,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
}, },
}; };
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS"); expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
}); });
it("keeps path avatars", () => { it("keeps path avatars", () => {
@@ -39,6 +37,6 @@ describe("resolveAssistantIdentity avatar normalization", () => {
}, },
}; };
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png"); expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
}); });
}); });
-66
View File
@@ -1,66 +0,0 @@
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");
});
});
+2 -40
View File
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
export function buildAvatarUrl(basePath: string, agentId: string): string { function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`; return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
} }
@@ -206,49 +206,11 @@ interface ServeIndexHtmlOpts {
agentId?: string; 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) { function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts; const { basePath, config, agentId } = opts;
const identity = config const identity = config
? resolveAssistantIdentity({ cfg: config, agentId }) ? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY; : 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("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8"); const raw = fs.readFileSync(indexPath, "utf8");
@@ -256,7 +218,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, { injectControlUiConfig(raw, {
basePath, basePath,
assistantName: identity.name, assistantName: identity.name,
assistantAvatar: avatarValue, assistantAvatar: identity.avatar,
}), }),
); );
} }
+1 -8
View File
@@ -38,7 +38,6 @@ import {
import { loadSessionEntry } from "../session-utils.js"; import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js"; import { resolveAssistantIdentity } from "../assistant-identity.js";
import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js"; import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
@@ -408,13 +407,7 @@ export const agentHandlers: GatewayRequestHandlers = {
} }
const cfg = loadConfig(); const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId }); const identity = resolveAssistantIdentity({ cfg, agentId });
const avatarValue = respond(true, identity, undefined);
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 }) => { "agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) { if (!validateAgentWaitParams(params)) {
+1 -1
View File
@@ -26,7 +26,7 @@ When you run `/new` to start a fresh session:
2. **Extracts conversation** - Reads the last 15 lines of conversation from the session 2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content 3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md` 4. **Saves to memory** - Creates a new file at `<workspace>/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path 5. **Logs completion** - Writes the file path to logs (no user-visible confirmation)
## Output Format ## Output Format
@@ -0,0 +1,43 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import handler from "./handler.js";
import { createHookEvent } from "../../hooks.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { makeTempWorkspace } from "../../../test-helpers/workspace.js";
describe("session-memory hook", () => {
it("writes a memory entry without queuing a user confirmation", async () => {
const workspaceDir = await makeTempWorkspace("clawdbot-session-memory-");
const cfg: ClawdbotConfig = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
};
const context: Record<string, unknown> = {
cfg,
sessionEntry: {
sessionId: "session-123",
sessionFile: "",
},
commandSource: "test",
};
const event = createHookEvent("command", "new", "agent:main:main", context);
await handler(event);
expect(event.messages).toEqual([]);
const memoryDir = path.join(workspaceDir, "memory");
const files = await fs.readdir(memoryDir);
expect(files).toHaveLength(1);
const entry = await fs.readFile(path.join(memoryDir, files[0] ?? ""), "utf-8");
expect(entry).toContain("Session Key");
expect(entry).toContain("session-123");
});
});
+1 -2
View File
@@ -158,8 +158,7 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean { function isAvatarUrl(value: string): boolean {
return ( return (
/^https?:\/\//i.test(value) || /^https?:\/\//i.test(value) ||
/^data:image\//i.test(value) || /^data:image\//i.test(value)
/^\//.test(value) // Relative paths from avatar endpoint
); );
} }