Compare commits

..
Author SHA1 Message Date
Peter Steinberger 97805e63be fix: keep session token totals in sync (#1440) (thanks @robbyczgw-cla) 2026-01-23 00:10:05 +00:00
Robby 01f44f13a1 style: fix formatting 2026-01-22 23:06:04 +00:00
Robby 5cfe6ff673 fix: update token count display after compaction (#1299) 2026-01-22 23:06:04 +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
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
33 changed files with 410 additions and 276 deletions

No files matched your search

+5 -2
View File
@@ -2,12 +2,15 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22 (Unreleased) ## 2026.1.22 (unreleased)
### Fixes ### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. - 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. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Node: launch node host service with `node run` (not `node start`) and align node host hints/docs. (#1461) Thanks @ameno-. - Auto-reply: keep cached context token count in sync after compaction. (#1440) 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
+1
View File
@@ -59,6 +59,7 @@ Manage the service:
```bash ```bash
clawdbot node status clawdbot node status
clawdbot node run
clawdbot node stop clawdbot node stop
clawdbot node restart clawdbot node restart
clawdbot node uninstall clawdbot node uninstall
+3 -2
View File
@@ -5,7 +5,7 @@ read_when:
--- ---
# Gateway architecture # Gateway architecture
Last updated: 2026-01-19 Last updated: 2026-01-22
## Overview ## Overview
@@ -34,7 +34,8 @@ Last updated: 2026-01-19
### 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`.
- 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`. - 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 bridge beacons ### 4) Node connects (role: node)
When a node bridge connection authenticates, the Gateway emits a presence entry When a node connects over the Gateway WebSocket with `role: node`, the Gateway
for that node and refreshes it periodically so it doesn’t expire. upserts a presence entry for that node (same flow as other WS clients).
## Merge + dedupe rules (why `instanceId` matters) ## 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 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.
+17 -15
View File
@@ -8,9 +8,11 @@ read_when:
# Nodes # 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: 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.
@@ -18,21 +20,23 @@ Notes:
## Pairing + status ## 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: Quick CLI:
```bash ```bash
clawdbot nodes pending clawdbot devices list
clawdbot nodes approve <requestId> clawdbot devices approve <requestId>
clawdbot nodes reject <requestId> clawdbot devices 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 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) ## Remote node host (system.run)
@@ -57,11 +61,9 @@ clawdbot node run --host <gateway-host> --port 18789 --display-name "Build Node"
```bash ```bash
clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node" clawdbot node install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node status clawdbot node start
``` ```
Install starts the service. Use `clawdbot node restart` if you need to re-launch it.
### Pair + name ### Pair + name
On the gateway host: On the gateway host:
@@ -277,7 +279,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
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. or for running a minimal node alongside a server.
Start it: Start it:
@@ -294,9 +296,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 bridge requires TLS. - Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
## Mac node mode ## Mac node mode
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac). - 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 bridge port and connects to `localhost`. - 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). 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. Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject. creates a device pairing request that must be approved.
### Approve a node ### Approve a node device
```bash ```bash
clawdbot nodes pending clawdbot devices list
clawdbot nodes approve <requestId> clawdbot devices approve <requestId>
clawdbot devices reject <requestId>
``` ```
### Where the state lives ### Where the state lives
Stored under `~/.clawdbot/nodes/`: Stored under `~/.clawdbot/devices/`:
- `pending.json` (short-lived; pending requests expire) - `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 ## Related docs
+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,sheets,docs` - `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
- `gog auth list` - `gog auth list`
Common commands Common commands
@@ -43,8 +43,6 @@ 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( expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
"The AI service returned an error. Please try again.",
);
}); });
}); });
@@ -17,4 +17,10 @@ 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(
"The AI service returned an error. Please try again.", "HTTP 500: Internal Server Error",
); );
}); });
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("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(); 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";
@@ -261,8 +269,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 (isRawApiErrorPayload(raw)) { if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
return "The AI service returned an error. Please try again."; return formatRawAssistantErrorForUi(raw);
} }
// Never return raw unhandled errors - log for debugging but return safe message // 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)) { if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return "The AI service returned an error. Please try again."; return formatRawAssistantErrorForUi(trimmed);
} }
if (ERROR_PREFIX_RE.test(trimmed)) { if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -303,7 +311,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) { if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out."; return "LLM request timed out.";
} }
return "The AI service returned an error. Please try again."; return formatRawAssistantErrorForUi(trimmed);
} }
return stripped; return stripped;
+27 -1
View File
@@ -1,7 +1,12 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent"; import {
createAgentSession,
estimateTokens,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
@@ -370,6 +375,26 @@ export async function compactEmbeddedPiSession(params: {
session.agent.replaceMessages(limited); session.agent.replaceMessages(limited);
} }
const result = await session.compact(params.customInstructions); const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction with the same context-usage heuristics.
let tokensAfter: number | undefined;
try {
const usage =
typeof session.getContextUsage === "function"
? session.getContextUsage()
: undefined;
let estimate = usage?.tokens;
if (!Number.isFinite(estimate) || !estimate || estimate <= 0) {
estimate = 0;
for (const message of session.messages) {
estimate += estimateTokens(message);
}
}
if (Number.isFinite(estimate) && estimate > 0 && estimate <= result.tokensBefore) {
tokensAfter = estimate;
}
} catch {
tokensAfter = undefined;
}
return { return {
ok: true, ok: true,
compacted: true, compacted: true,
@@ -377,6 +402,7 @@ export async function compactEmbeddedPiSession(params: {
summary: result.summary, summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId, firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore, tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details, details: result.details,
}, },
}; };
@@ -6,6 +6,7 @@ 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,
@@ -64,6 +65,12 @@ 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;
@@ -116,10 +123,15 @@ 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
View File
@@ -59,6 +59,7 @@ export type EmbeddedPiCompactResult = {
summary: string; summary: string;
firstKeptEntryId: string; firstKeptEntryId: string;
tokensBefore: number; tokensBefore: number;
tokensAfter?: number;
details?: unknown; details?: unknown;
}; };
}; };
+2 -1
View File
@@ -31,7 +31,8 @@ const subagentRuns = new Map<string, SubagentRunRecord>();
let sweeper: NodeJS.Timeout | null = null; let sweeper: NodeJS.Timeout | null = null;
let listenerStarted = false; let listenerStarted = false;
let listenerStop: (() => void) | null = null; let listenerStop: (() => void) | null = null;
let restoreAttempted = false; // Use var to avoid TDZ on circular init paths that can call restoreSubagentRunsOnce early.
var restoreAttempted = false;
function persistSubagentRuns() { function persistSubagentRuns() {
try { try {
+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); 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", 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. 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, parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
+1 -1
View File
@@ -42,7 +42,7 @@ describe("block streaming", () => {
}); });
async function waitForCalls(fn: () => number, calls: number) { async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 15000;
while (fn() < calls) { while (fn() < calls) {
if (Date.now() > deadline) { if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`); throw new Error(`Expected ${calls} call(s), got ${fn()}`);
+17 -10
View File
@@ -83,18 +83,13 @@ export const handleCompactCommand: CommandHandler = async (params) => {
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined, ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
}); });
const totalTokens =
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const compactLabel = result.ok const compactLabel = result.ok
? result.compacted ? result.compacted
? result.result?.tokensBefore ? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)` ? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})`
: "Compacted" : result.result?.tokensBefore
? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped" : "Compaction skipped"
: "Compaction failed"; : "Compaction failed";
if (result.ok && result.compacted) { if (result.ok && result.compacted) {
@@ -103,8 +98,20 @@ export const handleCompactCommand: CommandHandler = async (params) => {
sessionStore: params.sessionStore, sessionStore: params.sessionStore,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
storePath: params.storePath, storePath: params.storePath,
// Update token counts after compaction
tokensAfter: result.result?.tokensAfter,
}); });
} }
// Use the post-compaction token count for context summary if available
const tokensAfterCompaction = result.result?.tokensAfter;
const totalTokens =
tokensAfterCompaction ??
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const reason = result.reason?.trim(); const reason = result.reason?.trim();
const line = reason const line = reason
? `${compactLabel}: ${reason} • ${contextSummary}` ? `${compactLabel}: ${reason} • ${contextSummary}`
@@ -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;
@@ -77,100 +77,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).not.toContain("failed"); 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 () => { it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world"); const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = { const sessionEntry: SessionEntry = {
+97 -105
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,115 +288,111 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" && nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel); !supportsXHighThinking(resolvedProvider, resolvedModel);
let didPersistModel = false; 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 (shouldDowngradeXHigh) { if (directives.execSecurity) {
sessionEntry.thinkingLevel = "high"; sessionEntry.execSecurity = directives.execSecurity;
} }
if (directives.hasVerboseDirective && directives.verboseLevel) { if (directives.execAsk) {
applyVerboseOverride(sessionEntry, directives.verboseLevel); sessionEntry.execAsk = directives.execAsk;
} }
if (directives.hasReasoningDirective && directives.reasoningLevel) { if (directives.execNode) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel; sessionEntry.execNode = directives.execNode;
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". if (modelSelection) {
// Persist "off" explicitly so `/elevated off` actually overrides defaults. applyModelOverrideToSessionEntry({
sessionEntry.elevatedLevel = directives.elevatedLevel; entry: sessionEntry,
elevatedChanged = selection: modelSelection,
elevatedChanged || profileOverride,
(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 (directives.hasExecDirective && directives.hasExecOptions) { if (typeof directives.cap === "number") {
if (directives.execHost) { sessionEntry.queueCap = directives.cap;
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 (modelSelection) { if (directives.dropPolicy) {
applyModelOverrideToSessionEntry({ sessionEntry.queueDrop = directives.dropPolicy;
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
didPersistModel = true;
} }
if (directives.hasQueueDirective && directives.queueReset) { }
delete sessionEntry.queueMode; sessionEntry.updatedAt = Date.now();
delete sessionEntry.queueDebounceMs; sessionStore[sessionKey] = sessionEntry;
delete sessionEntry.queueCap; if (storePath) {
delete sessionEntry.queueDrop; await updateSessionStore(storePath, (store) => {
} else if (directives.hasQueueDirective) { store[sessionKey] = sessionEntry;
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode; });
if (typeof directives.debounceMs === "number") { }
sessionEntry.queueDebounceMs = directives.debounceMs; if (modelSelection) {
} const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (typeof directives.cap === "number") { if (nextLabel !== initialModelLabel) {
sessionEntry.queueCap = directives.cap; enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
}
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, sessionKey,
contextKey: "mode:elevated", contextKey: `model:${nextLabel}`,
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
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[] = [];
if (directives.hasThinkDirective && directives.thinkLevel) { 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}).`, `Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
); );
} }
if (modelSelection && didPersistModel) { if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`; const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label; const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push( parts.push(
@@ -460,10 +456,6 @@ export async function handleDirectiveOnly(params: {
if (profileOverride) { if (profileOverride) {
parts.push(`Auth profile set to ${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) { if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`)); parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
@@ -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"];
+35 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js"; import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { prependSystemEvents } from "./session-updates.js"; import { incrementCompactionCount, prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => { describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => { it("adds a local timestamp to queued system events by default", async () => {
@@ -29,3 +29,37 @@ describe("prependSystemEvents", () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });
describe("incrementCompactionCount", () => {
it("updates cached total tokens after compaction without clearing input/output", async () => {
const sessionKey = "agent:main:main";
const sessionStore = {
[sessionKey]: {
sessionId: "s1",
updatedAt: 10,
compactionCount: 1,
totalTokens: 9_000,
inputTokens: 111,
outputTokens: 222,
},
};
const now = 1234;
const nextCount = await incrementCompactionCount({
sessionEntry: sessionStore[sessionKey],
sessionStore,
sessionKey,
now,
tokensAfter: 2_000,
});
expect(nextCount).toBe(2);
expect(sessionStore[sessionKey]).toMatchObject({
compactionCount: 2,
totalTokens: 2_000,
inputTokens: 111,
outputTokens: 222,
updatedAt: now,
});
});
});
+21 -5
View File
@@ -237,23 +237,39 @@ export async function incrementCompactionCount(params: {
sessionKey?: string; sessionKey?: string;
storePath?: string; storePath?: string;
now?: number; now?: number;
/** Token count after compaction - if provided, updates cached context usage */
tokensAfter?: number;
}): Promise<number | undefined> { }): Promise<number | undefined> {
const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params; const {
sessionEntry,
sessionStore,
sessionKey,
storePath,
now = Date.now(),
tokensAfter,
} = params;
if (!sessionStore || !sessionKey) return undefined; if (!sessionStore || !sessionKey) return undefined;
const entry = sessionStore[sessionKey] ?? sessionEntry; const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) return undefined; if (!entry) return undefined;
const nextCount = (entry.compactionCount ?? 0) + 1; const nextCount = (entry.compactionCount ?? 0) + 1;
sessionStore[sessionKey] = { // Build update payload with compaction count and optionally updated context usage.
...entry, const updates: Partial<SessionEntry> = {
compactionCount: nextCount, compactionCount: nextCount,
updatedAt: now, updatedAt: now,
}; };
// If tokensAfter is provided, update the cached total to reflect post-compaction context size.
if (tokensAfter != null && tokensAfter > 0) {
updates.totalTokens = tokensAfter;
}
sessionStore[sessionKey] = {
...entry,
...updates,
};
if (storePath) { if (storePath) {
await updateSessionStore(storePath, (store) => { await updateSessionStore(storePath, (store) => {
store[sessionKey] = { store[sessionKey] = {
...store[sessionKey], ...store[sessionKey],
compactionCount: nextCount, ...updates,
updatedAt: now,
}; };
}); });
} }
+1 -1
View File
@@ -47,7 +47,7 @@ type NodeDaemonStatusOptions = {
}; };
function renderNodeServiceStartHints(): string[] { function renderNodeServiceStartHints(): string[] {
const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node run")]; const base = [formatCliCommand("clawdbot node install"), formatCliCommand("clawdbot node start")];
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return [ return [
+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", () => { 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", () => { 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)); 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}`; return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
} }
@@ -206,11 +206,49 @@ 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");
@@ -218,7 +256,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, { injectControlUiConfig(raw, {
basePath, basePath,
assistantName: identity.name, assistantName: identity.name,
assistantAvatar: identity.avatar, assistantAvatar: avatarValue,
}), }),
); );
} }
+8 -1
View File
@@ -38,6 +38,7 @@ 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";
@@ -407,7 +408,13 @@ export const agentHandlers: GatewayRequestHandlers = {
} }
const cfg = loadConfig(); const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId }); 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 }) => { "agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) { if (!validateAgentWaitParams(params)) {
+2 -1
View File
@@ -158,7 +158,8 @@ 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
); );
} }