Merge branch 'main' into feat/mattermost-channel
This commit is contained in:
@@ -80,58 +80,44 @@ describe("exec approvals", () => {
|
||||
if (process.platform !== "win32") {
|
||||
await fs.chmod(exePath, 0o755);
|
||||
}
|
||||
const prevPath = process.env.PATH;
|
||||
const prevPathExt = process.env.PATHEXT;
|
||||
process.env.PATH = binDir;
|
||||
if (process.platform === "win32") {
|
||||
process.env.PATHEXT = ".CMD";
|
||||
}
|
||||
|
||||
try {
|
||||
const approvalsFile = {
|
||||
version: 1,
|
||||
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
|
||||
agents: {
|
||||
main: {
|
||||
allowlist: [{ pattern: exePath }],
|
||||
},
|
||||
const approvalsFile = {
|
||||
version: 1,
|
||||
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
|
||||
agents: {
|
||||
main: {
|
||||
allowlist: [{ pattern: exePath }],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approvals.node.get") {
|
||||
return { file: approvalsFile };
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
return { payload: { success: true, stdout: "ok" } };
|
||||
}
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
ask: "on-miss",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call2", { command: `${exeName} --help` });
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(calls).toContain("exec.approvals.node.get");
|
||||
expect(calls).toContain("node.invoke");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
if (prevPathExt === undefined) {
|
||||
delete process.env.PATHEXT;
|
||||
} else {
|
||||
process.env.PATHEXT = prevPathExt;
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approvals.node.get") {
|
||||
return { file: approvalsFile };
|
||||
}
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
return { payload: { success: true, stdout: "ok" } };
|
||||
}
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
ask: "on-miss",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call2", {
|
||||
command: `"${exePath}" --help`,
|
||||
});
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(calls).toContain("exec.approvals.node.get");
|
||||
expect(calls).toContain("node.invoke");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,8 +43,6 @@ describe("formatAssistantErrorText", () => {
|
||||
const msg = makeAssistantError(
|
||||
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toBe(
|
||||
"The AI service returned an error. Please try again.",
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,4 +17,10 @@ describe("formatRawAssistantErrorForUi", () => {
|
||||
it("renders a generic unknown error message when raw is empty", () => {
|
||||
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
|
||||
});
|
||||
|
||||
it("formats plain HTTP status lines", () => {
|
||||
expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
|
||||
"HTTP 500: Internal Server Error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
|
||||
|
||||
it("sanitizes HTTP status errors with error hints", () => {
|
||||
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
|
||||
"The AI service returned an error. Please try again.",
|
||||
"HTTP 500: Internal Server Error",
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes raw API error payloads", () => {
|
||||
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
|
||||
expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
|
||||
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,6 +201,14 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!trimmed) return "LLM request failed with an unknown error.";
|
||||
|
||||
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
|
||||
if (httpMatch) {
|
||||
const rest = httpMatch[2].trim();
|
||||
if (!rest.startsWith("{")) {
|
||||
return `HTTP ${httpMatch[1]}: ${rest}`;
|
||||
}
|
||||
}
|
||||
|
||||
const info = parseApiErrorInfo(trimmed);
|
||||
if (info?.message) {
|
||||
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
|
||||
@@ -261,8 +269,8 @@ export function formatAssistantErrorText(
|
||||
return "The AI service is temporarily overloaded. Please try again in a moment.";
|
||||
}
|
||||
|
||||
if (isRawApiErrorPayload(raw)) {
|
||||
return "The AI service returned an error. Please try again.";
|
||||
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
|
||||
return formatRawAssistantErrorForUi(raw);
|
||||
}
|
||||
|
||||
// Never return raw unhandled errors - log for debugging but return safe message
|
||||
@@ -293,7 +301,7 @@ export function sanitizeUserFacingText(text: string): string {
|
||||
}
|
||||
|
||||
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
|
||||
return "The AI service returned an error. Please try again.";
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
|
||||
if (ERROR_PREFIX_RE.test(trimmed)) {
|
||||
@@ -303,7 +311,7 @@ export function sanitizeUserFacingText(text: string): string {
|
||||
if (isTimeoutErrorMessage(trimmed)) {
|
||||
return "LLM request timed out.";
|
||||
}
|
||||
return "The AI service returned an error. Please try again.";
|
||||
return formatRawAssistantErrorForUi(trimmed);
|
||||
}
|
||||
|
||||
return stripped;
|
||||
|
||||
@@ -6,9 +6,15 @@ export function isGoogleModelApi(api?: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
|
||||
if (api !== "google-antigravity") return false;
|
||||
return modelId?.toLowerCase().includes("claude") ?? false;
|
||||
export function isAntigravityClaude(params: {
|
||||
api?: string | null;
|
||||
provider?: string | null;
|
||||
modelId?: string;
|
||||
}): boolean {
|
||||
const provider = params.provider?.toLowerCase();
|
||||
const api = params.api?.toLowerCase();
|
||||
if (provider !== "google-antigravity" && api !== "google-antigravity") return false;
|
||||
return params.modelId?.toLowerCase().includes("claude") ?? false;
|
||||
}
|
||||
|
||||
export { sanitizeGoogleTurnOrdering };
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||
});
|
||||
|
||||
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
|
||||
it("drops unsigned thinking blocks for Antigravity Claude", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
@@ -107,11 +107,37 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
sessionId: "session:antigravity-claude",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
|
||||
expect(assistant).toBeUndefined();
|
||||
});
|
||||
|
||||
it("maps base64 signatures to thinkingSignature for Antigravity Claude", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "reasoning", signature: "c2ln" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "google-antigravity",
|
||||
modelId: "anthropic/claude-3.5-sonnet",
|
||||
sessionManager,
|
||||
sessionId: "session:antigravity-claude",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string; thinking?: string }>;
|
||||
content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
|
||||
};
|
||||
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
|
||||
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
|
||||
expect(assistant.content?.[0]?.thinkingSignature).toBe("c2ln");
|
||||
});
|
||||
|
||||
it("preserves order for mixed assistant content", async () => {
|
||||
|
||||
@@ -55,6 +55,15 @@ const MISTRAL_MODEL_HINTS = [
|
||||
"ministral",
|
||||
"mistralai",
|
||||
];
|
||||
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
function isValidAntigravitySignature(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed.length % 4 !== 0) return false;
|
||||
return ANTIGRAVITY_SIGNATURE_RE.test(trimmed);
|
||||
}
|
||||
|
||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||
if (!modelApi) return false;
|
||||
@@ -69,6 +78,66 @@ function isMistralModel(params: { provider?: string | null; modelId?: string | n
|
||||
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
|
||||
}
|
||||
|
||||
function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
|
||||
let touched = false;
|
||||
const out: AgentMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object" || msg.role !== "assistant") {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
||||
if (!Array.isArray(assistant.content)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
type AssistantContentBlock = Extract<AgentMessage, { role: "assistant" }>["content"][number];
|
||||
const nextContent: AssistantContentBlock[] = [];
|
||||
let contentChanged = false;
|
||||
for (const block of assistant.content) {
|
||||
if (
|
||||
!block ||
|
||||
typeof block !== "object" ||
|
||||
(block as { type?: unknown }).type !== "thinking"
|
||||
) {
|
||||
nextContent.push(block);
|
||||
continue;
|
||||
}
|
||||
const rec = block as {
|
||||
thinkingSignature?: unknown;
|
||||
signature?: unknown;
|
||||
thought_signature?: unknown;
|
||||
thoughtSignature?: unknown;
|
||||
};
|
||||
const candidate =
|
||||
rec.thinkingSignature ?? rec.signature ?? rec.thought_signature ?? rec.thoughtSignature;
|
||||
if (!isValidAntigravitySignature(candidate)) {
|
||||
contentChanged = true;
|
||||
continue;
|
||||
}
|
||||
if (rec.thinkingSignature !== candidate) {
|
||||
const nextBlock = {
|
||||
...(block as unknown as Record<string, unknown>),
|
||||
thinkingSignature: candidate,
|
||||
} as AssistantContentBlock;
|
||||
nextContent.push(nextBlock);
|
||||
contentChanged = true;
|
||||
} else {
|
||||
nextContent.push(block);
|
||||
}
|
||||
}
|
||||
if (contentChanged) {
|
||||
touched = true;
|
||||
}
|
||||
if (nextContent.length === 0) {
|
||||
touched = true;
|
||||
continue;
|
||||
}
|
||||
out.push(contentChanged ? { ...assistant, content: nextContent } : msg);
|
||||
}
|
||||
return touched ? out : messages;
|
||||
}
|
||||
|
||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
if (Array.isArray(schema)) {
|
||||
@@ -209,7 +278,11 @@ export async function sanitizeSessionHistory(params: {
|
||||
sessionManager: SessionManager;
|
||||
sessionId: string;
|
||||
}): Promise<AgentMessage[]> {
|
||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||
const isAntigravityClaudeModel = isAntigravityClaude({
|
||||
api: params.modelApi,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
const modelId = (params.modelId ?? "").toLowerCase();
|
||||
const isOpenRouterGemini =
|
||||
@@ -221,12 +294,15 @@ export async function sanitizeSessionHistory(params: {
|
||||
sanitizeToolCallIds,
|
||||
toolCallIdMode,
|
||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||
preserveSignatures: isAntigravityClaudeModel,
|
||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||
? { allowBase64Only: true, includeCamelCase: true }
|
||||
: undefined,
|
||||
});
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||
const sanitizedThinking = isAntigravityClaudeModel
|
||||
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
|
||||
: sanitizedImages;
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
|
||||
|
||||
return applyGoogleTurnOrderingFix({
|
||||
messages: repairedTools,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import {
|
||||
formatAssistantErrorText,
|
||||
formatRawAssistantErrorForUi,
|
||||
getApiErrorPayloadFingerprint,
|
||||
isRawApiErrorPayload,
|
||||
normalizeTextForComparison,
|
||||
@@ -64,6 +65,12 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
const rawErrorFingerprint = rawErrorMessage
|
||||
? getApiErrorPayloadFingerprint(rawErrorMessage)
|
||||
: null;
|
||||
const formattedRawErrorMessage = rawErrorMessage
|
||||
? formatRawAssistantErrorForUi(rawErrorMessage)
|
||||
: null;
|
||||
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
|
||||
? normalizeTextForComparison(formattedRawErrorMessage)
|
||||
: null;
|
||||
const normalizedRawErrorText = rawErrorMessage
|
||||
? normalizeTextForComparison(rawErrorMessage)
|
||||
: null;
|
||||
@@ -116,10 +123,15 @@ export function buildEmbeddedRunPayloads(params: {
|
||||
if (trimmed === genericErrorText) return true;
|
||||
}
|
||||
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
|
||||
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
|
||||
if (normalizedRawErrorText) {
|
||||
const normalized = normalizeTextForComparison(trimmed);
|
||||
if (normalized && normalized === normalizedRawErrorText) return true;
|
||||
}
|
||||
if (normalizedFormattedRawErrorMessage) {
|
||||
const normalized = normalizeTextForComparison(trimmed);
|
||||
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
|
||||
}
|
||||
if (rawErrorFingerprint) {
|
||||
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
|
||||
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
|
||||
|
||||
@@ -17,8 +17,9 @@ function buildSkillsSection(params: {
|
||||
isMinimal: boolean;
|
||||
readToolName: string;
|
||||
}) {
|
||||
if (params.isMinimal) return [];
|
||||
const trimmed = params.skillsPrompt?.trim();
|
||||
if (!trimmed || params.isMinimal) return [];
|
||||
if (!trimmed) return [];
|
||||
return [
|
||||
"## Skills (mandatory)",
|
||||
"Before replying: scan <available_skills> <description> entries.",
|
||||
@@ -211,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
sessions_send: "Send a message to another session/sub-agent",
|
||||
sessions_spawn: "Spawn a sub-agent session",
|
||||
session_status:
|
||||
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
|
||||
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
|
||||
image: "Analyze an image with the configured image model",
|
||||
};
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
|
||||
label: "Session Status",
|
||||
name: "session_status",
|
||||
description:
|
||||
"Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
|
||||
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
|
||||
parameters: SessionStatusToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
|
||||
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string;
|
||||
isGroup: boolean;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
|
||||
export async function handleDirectiveOnly(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
directives: InlineDirectives;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
elevatedEnabled: boolean;
|
||||
@@ -288,113 +288,111 @@ export async function handleDirectiveOnly(params: {
|
||||
nextThinkLevel === "xhigh" &&
|
||||
!supportsXHighThinking(resolvedProvider, resolvedModel);
|
||||
|
||||
if (sessionEntry && sessionStore && sessionKey) {
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
const prevElevatedLevel =
|
||||
currentElevatedLevel ??
|
||||
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
|
||||
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
|
||||
const prevReasoningLevel =
|
||||
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
|
||||
let elevatedChanged =
|
||||
directives.hasElevatedDirective &&
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
|
||||
else sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (directives.hasExecDirective && directives.hasExecOptions) {
|
||||
if (directives.execHost) {
|
||||
sessionEntry.execHost = directives.execHost;
|
||||
}
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
|
||||
else sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
if (directives.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
}
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
}
|
||||
if (directives.hasExecDirective && directives.hasExecOptions) {
|
||||
if (directives.execHost) {
|
||||
sessionEntry.execHost = directives.execHost;
|
||||
}
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
}
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
}
|
||||
if (directives.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
}
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
|
||||
sessionKey,
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
if (nextLabel !== initialModelLabel) {
|
||||
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
|
||||
sessionKey,
|
||||
contextKey: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (elevatedChanged) {
|
||||
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
|
||||
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
|
||||
sessionKey,
|
||||
contextKey: "mode:elevated",
|
||||
});
|
||||
}
|
||||
if (reasoningChanged) {
|
||||
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
|
||||
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
|
||||
sessionKey,
|
||||
contextKey: "mode:reasoning",
|
||||
});
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
|
||||
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];
|
||||
|
||||
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
|
||||
workspaceDir: string;
|
||||
agentCfg: AgentDefaults;
|
||||
sessionCtx: TemplateContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];
|
||||
|
||||
@@ -1 +1 @@
|
||||
70ce2f8889599d5d76bccea69516e3136fd25fd32e43fe055d05faca822b47c7
|
||||
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||
import { visibleWidth } from "../terminal/ansi.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { pickTagline, type TaglineOptions } from "./tagline.js";
|
||||
|
||||
type BannerOptions = TaglineOptions & {
|
||||
argv?: string[];
|
||||
commit?: string | null;
|
||||
columns?: number;
|
||||
richTty?: boolean;
|
||||
};
|
||||
|
||||
@@ -36,12 +38,28 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
|
||||
const tagline = pickTagline(options);
|
||||
const rich = options.richTty ?? isRich();
|
||||
const title = "🦞 Clawdbot";
|
||||
const prefix = "🦞 ";
|
||||
const columns = options.columns ?? process.stdout.columns ?? 120;
|
||||
const plainFullLine = `${title} ${version} (${commitLabel}) — ${tagline}`;
|
||||
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
|
||||
if (rich) {
|
||||
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||
if (fitsOnOneLine) {
|
||||
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||
`(${commitLabel})`,
|
||||
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||
}
|
||||
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
|
||||
`(${commitLabel})`,
|
||||
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||
)}`;
|
||||
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
|
||||
return `${line1}\n${line2}`;
|
||||
}
|
||||
return `${title} ${version} (${commitLabel}) — ${tagline}`;
|
||||
if (fitsOnOneLine) {
|
||||
return plainFullLine;
|
||||
}
|
||||
const line1 = `${title} ${version} (${commitLabel})`;
|
||||
const line2 = `${" ".repeat(prefix.length)}— ${tagline}`;
|
||||
return `${line1}\n${line2}`;
|
||||
}
|
||||
|
||||
const LOBSTER_ASCII = [
|
||||
|
||||
@@ -2,10 +2,12 @@ import type { Command } from "commander";
|
||||
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
|
||||
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
|
||||
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
|
||||
import { withProgress } from "../progress.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import {
|
||||
@@ -58,6 +60,41 @@ function runGatewayCommand(action: () => Promise<void>, label?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function parseDaysOption(raw: unknown, fallback = 30): number {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
|
||||
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
|
||||
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
|
||||
const lines = [
|
||||
colorize(rich, theme.heading, `Usage cost (${days} days)`),
|
||||
`${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`,
|
||||
];
|
||||
|
||||
if (summary.totals.missingCostEntries > 0) {
|
||||
lines.push(
|
||||
`${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`,
|
||||
);
|
||||
}
|
||||
|
||||
const latest = summary.daily.at(-1);
|
||||
if (latest) {
|
||||
const latestCost = formatUsd(latest.totalCost) ?? "$0.00";
|
||||
const latestTokens = formatTokenCount(latest.totalTokens) ?? "0";
|
||||
lines.push(
|
||||
`${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function registerGatewayCli(program: Command) {
|
||||
const gateway = addGatewayRunCommand(
|
||||
program
|
||||
@@ -160,6 +197,28 @@ export function registerGatewayCli(program: Command) {
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("usage-cost")
|
||||
.description("Fetch usage cost summary from session logs")
|
||||
.option("--days <days>", "Number of days to include", "30")
|
||||
.action(async (opts) => {
|
||||
await runGatewayCommand(async () => {
|
||||
const days = parseDaysOption(opts.days);
|
||||
const result = await callGatewayCli("usage.cost", opts, { days });
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const rich = isRich();
|
||||
const summary = result as CostUsageSummary;
|
||||
for (const line of renderCostUsageSummary(summary, days, rich)) {
|
||||
defaultRuntime.log(line);
|
||||
}
|
||||
}, "Gateway usage cost failed");
|
||||
}),
|
||||
);
|
||||
|
||||
gatewayCallOpts(
|
||||
gateway
|
||||
.command("health")
|
||||
|
||||
@@ -48,7 +48,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
"Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)",
|
||||
false,
|
||||
)
|
||||
.option("--flow <flow>", "Wizard flow: quickstart|advanced")
|
||||
.option("--flow <flow>", "Wizard flow: quickstart|advanced|manual")
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
@@ -106,7 +106,7 @@ export function registerOnboardCommand(program: Command) {
|
||||
workspace: opts.workspace as string | undefined,
|
||||
nonInteractive: Boolean(opts.nonInteractive),
|
||||
acceptRisk: Boolean(opts.acceptRisk),
|
||||
flow: opts.flow as "quickstart" | "advanced" | undefined,
|
||||
flow: opts.flow as "quickstart" | "advanced" | "manual" | undefined,
|
||||
mode: opts.mode as "local" | "remote" | undefined,
|
||||
authChoice: opts.authChoice as AuthChoice | undefined,
|
||||
tokenProvider: opts.tokenProvider as string | undefined,
|
||||
|
||||
@@ -42,7 +42,8 @@ export type ProviderChoice = ChannelChoice;
|
||||
|
||||
export type OnboardOptions = {
|
||||
mode?: OnboardMode;
|
||||
flow?: "quickstart" | "advanced";
|
||||
/** "manual" is an alias for "advanced". */
|
||||
flow?: "quickstart" | "advanced" | "manual";
|
||||
workspace?: string;
|
||||
nonInteractive?: boolean;
|
||||
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
|
||||
|
||||
@@ -12,7 +12,9 @@ import type { OnboardOptions } from "./onboard-types.js";
|
||||
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
|
||||
assertSupportedRuntime(runtime);
|
||||
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
|
||||
const normalizedOpts = authChoice === opts.authChoice ? opts : { ...opts, authChoice };
|
||||
const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
|
||||
const normalizedOpts =
|
||||
authChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice, flow };
|
||||
|
||||
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
|
||||
runtime.error(
|
||||
|
||||
@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
|
||||
runtime?: GatewayRuntimePreference;
|
||||
nodePath?: string;
|
||||
}): Promise<GatewayProgramArgs> {
|
||||
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
|
||||
const args = ["node", "run", "--host", params.host, "--port", String(params.port)];
|
||||
if (params.tls || params.tlsFingerprint) args.push("--tls");
|
||||
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
|
||||
if (params.nodeId) args.push("--node-id", params.nodeId);
|
||||
|
||||
44
src/gateway/assistant-identity.test.ts
Normal file
44
src/gateway/assistant-identity.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
|
||||
describe("resolveAssistantIdentity avatar normalization", () => {
|
||||
it("drops sentence-like avatar placeholders", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: "workspace-relative path, http(s) URL, or data URI",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(
|
||||
DEFAULT_ASSISTANT_IDENTITY.avatar,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps short text avatars", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: "PS",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
|
||||
});
|
||||
|
||||
it("keeps path avatars", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
ui: {
|
||||
assistant: {
|
||||
avatar: "avatars/clawd.png",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png");
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,25 @@ function coerceIdentityValue(value: string | undefined, maxLength: number): stri
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(value) || /^data:image\//i.test(value);
|
||||
}
|
||||
|
||||
function looksLikeAvatarPath(value: string): boolean {
|
||||
if (/[\\/]/.test(value)) return true;
|
||||
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeAvatarValue(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (isAvatarUrl(trimmed)) return trimmed;
|
||||
if (looksLikeAvatarPath(trimmed)) return trimmed;
|
||||
if (!/\s/.test(trimmed) && trimmed.length <= 4) return trimmed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveAssistantIdentity(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId?: string | null;
|
||||
@@ -43,12 +62,15 @@ export function resolveAssistantIdentity(params: {
|
||||
coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ??
|
||||
DEFAULT_ASSISTANT_IDENTITY.name;
|
||||
|
||||
const avatarCandidates = [
|
||||
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR),
|
||||
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR),
|
||||
];
|
||||
const avatar =
|
||||
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR) ??
|
||||
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR) ??
|
||||
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
|
||||
DEFAULT_ASSISTANT_IDENTITY.avatar;
|
||||
|
||||
return { agentId, name, avatar };
|
||||
|
||||
66
src/gateway/control-ui.test.ts
Normal file
66
src/gateway/control-ui.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function buildAvatarUrl(basePath: string, agentId: string): string {
|
||||
export function buildAvatarUrl(basePath: string, agentId: string): string {
|
||||
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
|
||||
}
|
||||
|
||||
@@ -206,11 +206,49 @@ interface ServeIndexHtmlOpts {
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
function looksLikeLocalAvatarPath(value: string): boolean {
|
||||
if (/[\\/]/.test(value)) return true;
|
||||
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
|
||||
}
|
||||
|
||||
export function resolveAssistantAvatarUrl(params: {
|
||||
avatar?: string | null;
|
||||
agentId?: string | null;
|
||||
basePath?: string;
|
||||
}): string | undefined {
|
||||
const avatar = params.avatar?.trim();
|
||||
if (!avatar) return undefined;
|
||||
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
|
||||
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
|
||||
if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
|
||||
return `${basePath}${avatar}`;
|
||||
}
|
||||
if (avatar.startsWith(baseAvatarPrefix)) return avatar;
|
||||
|
||||
if (!params.agentId) return avatar;
|
||||
if (looksLikeLocalAvatarPath(avatar)) {
|
||||
return buildAvatarUrl(basePath, params.agentId);
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
|
||||
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
|
||||
const { basePath, config, agentId } = opts;
|
||||
const identity = config
|
||||
? resolveAssistantIdentity({ cfg: config, agentId })
|
||||
: DEFAULT_ASSISTANT_IDENTITY;
|
||||
const resolvedAgentId =
|
||||
typeof (identity as { agentId?: string }).agentId === "string"
|
||||
? (identity as { agentId?: string }).agentId
|
||||
: agentId;
|
||||
const avatarValue =
|
||||
resolveAssistantAvatarUrl({
|
||||
avatar: identity.avatar,
|
||||
agentId: resolvedAgentId,
|
||||
basePath,
|
||||
}) ?? identity.avatar;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
const raw = fs.readFileSync(indexPath, "utf8");
|
||||
@@ -218,7 +256,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
|
||||
injectControlUiConfig(raw, {
|
||||
basePath,
|
||||
assistantName: identity.name,
|
||||
assistantAvatar: identity.avatar,
|
||||
assistantAvatar: avatarValue,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,8 @@ import {
|
||||
SessionsListParamsSchema,
|
||||
type SessionsPatchParams,
|
||||
SessionsPatchParamsSchema,
|
||||
type SessionsPreviewParams,
|
||||
SessionsPreviewParamsSchema,
|
||||
type SessionsResetParams,
|
||||
SessionsResetParamsSchema,
|
||||
type SessionsResolveParams,
|
||||
@@ -229,6 +231,9 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
|
||||
);
|
||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
|
||||
SessionsPreviewParamsSchema,
|
||||
);
|
||||
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||
SessionsResolveParamsSchema,
|
||||
);
|
||||
@@ -376,6 +381,7 @@ export {
|
||||
NodeListParamsSchema,
|
||||
NodeInvokeParamsSchema,
|
||||
SessionsListParamsSchema,
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsPatchParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsDeleteParamsSchema,
|
||||
@@ -488,6 +494,7 @@ export type {
|
||||
NodeInvokeResultParams,
|
||||
NodeEventParams,
|
||||
SessionsListParams,
|
||||
SessionsPreviewParams,
|
||||
SessionsResolveParams,
|
||||
SessionsPatchParams,
|
||||
SessionsResetParams,
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
SessionsDeleteParamsSchema,
|
||||
SessionsListParamsSchema,
|
||||
SessionsPatchParamsSchema,
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsResolveParamsSchema,
|
||||
} from "./sessions.js";
|
||||
@@ -155,6 +156,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
NodeEventParams: NodeEventParamsSchema,
|
||||
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||
SessionsListParams: SessionsListParamsSchema,
|
||||
SessionsPreviewParams: SessionsPreviewParamsSchema,
|
||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||
SessionsResetParams: SessionsResetParamsSchema,
|
||||
|
||||
@@ -26,6 +26,15 @@ export const SessionsListParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsPreviewParamsSchema = Type.Object(
|
||||
{
|
||||
keys: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
maxChars: Type.Optional(Type.Integer({ minimum: 20 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const SessionsResolveParamsSchema = Type.Object(
|
||||
{
|
||||
key: Type.Optional(NonEmptyString),
|
||||
|
||||
@@ -101,6 +101,7 @@ import type {
|
||||
SessionsDeleteParamsSchema,
|
||||
SessionsListParamsSchema,
|
||||
SessionsPatchParamsSchema,
|
||||
SessionsPreviewParamsSchema,
|
||||
SessionsResetParamsSchema,
|
||||
SessionsResolveParamsSchema,
|
||||
} from "./sessions.js";
|
||||
@@ -144,6 +145,7 @@ export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
||||
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
|
||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||
|
||||
@@ -34,6 +34,7 @@ const BASE_METHODS = [
|
||||
"voicewake.get",
|
||||
"voicewake.set",
|
||||
"sessions.list",
|
||||
"sessions.preview",
|
||||
"sessions.patch",
|
||||
"sessions.reset",
|
||||
"sessions.delete",
|
||||
|
||||
@@ -59,6 +59,7 @@ const READ_METHODS = new Set([
|
||||
"skills.status",
|
||||
"voicewake.get",
|
||||
"sessions.list",
|
||||
"sessions.preview",
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.runs",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { loadSessionEntry } from "../session-utils.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
||||
import { resolveAssistantAvatarUrl } from "../control-ui.js";
|
||||
import { waitForAgentJob } from "./agent-job.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
@@ -407,7 +408,13 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const identity = resolveAssistantIdentity({ cfg, agentId });
|
||||
respond(true, identity, undefined);
|
||||
const avatarValue =
|
||||
resolveAssistantAvatarUrl({
|
||||
avatar: identity.avatar,
|
||||
agentId: identity.agentId,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
}) ?? identity.avatar;
|
||||
respond(true, { ...identity, avatar: avatarValue }, undefined);
|
||||
},
|
||||
"agent.wait": async ({ params, respond }) => {
|
||||
if (!validateAgentWaitParams(params)) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
|
||||
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
snapshotSessionOrigin,
|
||||
resolveMainSessionKey,
|
||||
type SessionEntry,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
validateSessionsDeleteParams,
|
||||
validateSessionsListParams,
|
||||
validateSessionsPatchParams,
|
||||
validateSessionsPreviewParams,
|
||||
validateSessionsResetParams,
|
||||
validateSessionsResolveParams,
|
||||
} from "../protocol/index.js";
|
||||
@@ -27,9 +29,12 @@ import {
|
||||
listSessionsFromStore,
|
||||
loadCombinedSessionStoreForGateway,
|
||||
loadSessionEntry,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionTranscriptCandidates,
|
||||
type SessionsPatchResult,
|
||||
type SessionsPreviewEntry,
|
||||
type SessionsPreviewResult,
|
||||
} from "../session-utils.js";
|
||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||
@@ -59,6 +64,74 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
respond(true, result, undefined);
|
||||
},
|
||||
"sessions.preview": ({ params, respond }) => {
|
||||
if (!validateSessionsPreviewParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid sessions.preview params: ${formatValidationErrors(
|
||||
validateSessionsPreviewParams.errors,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params as import("../protocol/index.js").SessionsPreviewParams;
|
||||
const keysRaw = Array.isArray(p.keys) ? p.keys : [];
|
||||
const keys = keysRaw
|
||||
.map((key) => String(key ?? "").trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 64);
|
||||
const limit =
|
||||
typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12;
|
||||
const maxChars =
|
||||
typeof p.maxChars === "number" && Number.isFinite(p.maxChars)
|
||||
? Math.max(20, p.maxChars)
|
||||
: 240;
|
||||
|
||||
if (keys.length === 0) {
|
||||
respond(true, { ts: Date.now(), previews: [] } satisfies SessionsPreviewResult, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const storeCache = new Map<string, Record<string, SessionEntry>>();
|
||||
const previews: SessionsPreviewEntry[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||
const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath);
|
||||
storeCache.set(target.storePath, store);
|
||||
const entry =
|
||||
target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ??
|
||||
store[target.canonicalKey];
|
||||
if (!entry?.sessionId) {
|
||||
previews.push({ key, status: "missing", items: [] });
|
||||
continue;
|
||||
}
|
||||
const items = readSessionPreviewItemsFromTranscript(
|
||||
entry.sessionId,
|
||||
target.storePath,
|
||||
entry.sessionFile,
|
||||
target.agentId,
|
||||
limit,
|
||||
maxChars,
|
||||
);
|
||||
previews.push({
|
||||
key,
|
||||
status: items.length > 0 ? "ok" : "empty",
|
||||
items,
|
||||
});
|
||||
} catch {
|
||||
previews.push({ key, status: "error", items: [] });
|
||||
}
|
||||
}
|
||||
|
||||
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
|
||||
},
|
||||
"sessions.resolve": ({ params, respond }) => {
|
||||
if (!validateSessionsResolveParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -1,16 +1,78 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const COST_USAGE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
type CostUsageCacheEntry = {
|
||||
summary?: CostUsageSummary;
|
||||
updatedAt?: number;
|
||||
inFlight?: Promise<CostUsageSummary>;
|
||||
};
|
||||
|
||||
const costUsageCache = new Map<number, CostUsageCacheEntry>();
|
||||
|
||||
const parseDays = (raw: unknown): number => {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
||||
}
|
||||
return 30;
|
||||
};
|
||||
|
||||
async function loadCostUsageSummaryCached(params: {
|
||||
days: number;
|
||||
config: ReturnType<typeof loadConfig>;
|
||||
}): Promise<CostUsageSummary> {
|
||||
const days = Math.max(1, params.days);
|
||||
const now = Date.now();
|
||||
const cached = costUsageCache.get(days);
|
||||
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
|
||||
return cached.summary;
|
||||
}
|
||||
|
||||
if (cached?.inFlight) {
|
||||
if (cached.summary) return cached.summary;
|
||||
return await cached.inFlight;
|
||||
}
|
||||
|
||||
const entry: CostUsageCacheEntry = cached ?? {};
|
||||
const inFlight = loadCostUsageSummary({ days, config: params.config })
|
||||
.then((summary) => {
|
||||
costUsageCache.set(days, { summary, updatedAt: Date.now() });
|
||||
return summary;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (entry.summary) return entry.summary;
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
const current = costUsageCache.get(days);
|
||||
if (current?.inFlight === inFlight) {
|
||||
current.inFlight = undefined;
|
||||
costUsageCache.set(days, current);
|
||||
}
|
||||
});
|
||||
|
||||
entry.inFlight = inFlight;
|
||||
costUsageCache.set(days, entry);
|
||||
|
||||
if (entry.summary) return entry.summary;
|
||||
return await inFlight;
|
||||
}
|
||||
|
||||
export const usageHandlers: GatewayRequestHandlers = {
|
||||
"usage.status": async ({ respond }) => {
|
||||
const summary = await loadProviderUsageSummary();
|
||||
respond(true, summary, undefined);
|
||||
},
|
||||
"usage.cost": async ({ respond }) => {
|
||||
"usage.cost": async ({ respond, params }) => {
|
||||
const config = loadConfig();
|
||||
const summary = await loadCostUsageSummary({ days: 30, config });
|
||||
const days = parseDays(params?.days);
|
||||
const summary = await loadCostUsageSummaryCached({ days, config });
|
||||
respond(true, summary, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -103,6 +103,7 @@ describe("gateway server sessions", () => {
|
||||
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
|
||||
expect.arrayContaining([
|
||||
"sessions.list",
|
||||
"sessions.preview",
|
||||
"sessions.patch",
|
||||
"sessions.reset",
|
||||
"sessions.delete",
|
||||
@@ -338,6 +339,53 @@ describe("gateway server sessions", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("sessions.preview returns transcript previews", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-preview-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
testState.sessionStorePath = storePath;
|
||||
const sessionId = "sess-preview";
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
||||
JSON.stringify({
|
||||
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
|
||||
}),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
|
||||
];
|
||||
await fs.writeFile(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const preview = await rpcReq<{
|
||||
previews: Array<{
|
||||
key: string;
|
||||
status: string;
|
||||
items: Array<{ role: string; text: string }>;
|
||||
}>;
|
||||
}>(ws, "sessions.preview", { keys: ["main"], limit: 3, maxChars: 120 });
|
||||
|
||||
expect(preview.ok).toBe(true);
|
||||
const entry = preview.payload?.previews[0];
|
||||
expect(entry?.key).toBe("main");
|
||||
expect(entry?.status).toBe("ok");
|
||||
expect(entry?.items.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
||||
expect(entry?.items[1]?.text).toContain("call weather");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("sessions.delete rejects main and aborts active runs", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
} from "./session-utils.fs.js";
|
||||
|
||||
describe("readFirstUserMessageFromTranscript", () => {
|
||||
@@ -341,3 +342,65 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
||||
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSessionPreviewItemsFromTranscript", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-preview-test-"));
|
||||
storePath = path.join(tmpDir, "sessions.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("returns recent preview items with tool summary", () => {
|
||||
const sessionId = "preview-session";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
||||
JSON.stringify({
|
||||
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
|
||||
}),
|
||||
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readSessionPreviewItemsFromTranscript(
|
||||
sessionId,
|
||||
storePath,
|
||||
undefined,
|
||||
undefined,
|
||||
3,
|
||||
120,
|
||||
);
|
||||
|
||||
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
||||
expect(result[1]?.text).toContain("call weather");
|
||||
});
|
||||
|
||||
test("truncates preview text to max chars", () => {
|
||||
const sessionId = "preview-truncate";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const longText = "a".repeat(60);
|
||||
const lines = [JSON.stringify({ message: { role: "assistant", content: longText } })];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readSessionPreviewItemsFromTranscript(
|
||||
sessionId,
|
||||
storePath,
|
||||
undefined,
|
||||
undefined,
|
||||
1,
|
||||
24,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.text.length).toBe(24);
|
||||
expect(result[0]?.text.endsWith("...")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||
import { stripEnvelope } from "./chat-sanitize.js";
|
||||
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||
|
||||
export function readSessionMessages(
|
||||
sessionId: string,
|
||||
@@ -189,3 +191,202 @@ export function readLastMessagePreviewFromTranscript(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024];
|
||||
const PREVIEW_MAX_LINES = 200;
|
||||
|
||||
type TranscriptContentEntry = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type TranscriptPreviewMessage = {
|
||||
role?: string;
|
||||
content?: string | TranscriptContentEntry[];
|
||||
text?: string;
|
||||
toolName?: string;
|
||||
tool_name?: string;
|
||||
};
|
||||
|
||||
function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] {
|
||||
if (isTool) return "tool";
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "user":
|
||||
return "user";
|
||||
case "assistant":
|
||||
return "assistant";
|
||||
case "system":
|
||||
return "system";
|
||||
case "tool":
|
||||
return "tool";
|
||||
default:
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
function truncatePreviewText(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0 || text.length <= maxChars) return text;
|
||||
if (maxChars <= 3) return text.slice(0, maxChars);
|
||||
return `${text.slice(0, maxChars - 3)}...`;
|
||||
}
|
||||
|
||||
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
||||
if (typeof message.content === "string") {
|
||||
const trimmed = message.content.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const parts = message.content
|
||||
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
|
||||
.filter((text) => text.trim().length > 0);
|
||||
if (parts.length > 0) {
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
}
|
||||
if (typeof message.text === "string") {
|
||||
const trimmed = message.text.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||
if (message.toolName || message.tool_name) return true;
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
return message.content.some((entry) => {
|
||||
if (entry?.name) return true;
|
||||
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
|
||||
return raw === "toolcall" || raw === "tool_call";
|
||||
});
|
||||
}
|
||||
|
||||
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||
const names: string[] = [];
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const entry of message.content) {
|
||||
if (typeof entry?.name === "string" && entry.name.trim()) {
|
||||
names.push(entry.name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
|
||||
if (typeof toolName === "string" && toolName.trim()) {
|
||||
names.push(toolName.trim());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||
if (!Array.isArray(message.content)) return null;
|
||||
for (const entry of message.content) {
|
||||
const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : "";
|
||||
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") continue;
|
||||
return `[${raw}]`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPreviewItems(
|
||||
messages: TranscriptPreviewMessage[],
|
||||
maxItems: number,
|
||||
maxChars: number,
|
||||
): SessionPreviewItem[] {
|
||||
const items: SessionPreviewItem[] = [];
|
||||
for (const message of messages) {
|
||||
const toolCall = isToolCall(message);
|
||||
const role = normalizeRole(message.role, toolCall);
|
||||
let text = extractPreviewText(message);
|
||||
if (!text) {
|
||||
const toolNames = extractToolNames(message);
|
||||
if (toolNames.length > 0) {
|
||||
const shown = toolNames.slice(0, 2);
|
||||
const overflow = toolNames.length - shown.length;
|
||||
text = `call ${shown.join(", ")}`;
|
||||
if (overflow > 0) text += ` +${overflow}`;
|
||||
}
|
||||
}
|
||||
if (!text) {
|
||||
text = extractMediaSummary(message);
|
||||
}
|
||||
if (!text) continue;
|
||||
let trimmed = text.trim();
|
||||
if (!trimmed) continue;
|
||||
if (role === "user") {
|
||||
trimmed = stripEnvelope(trimmed);
|
||||
}
|
||||
trimmed = truncatePreviewText(trimmed, maxChars);
|
||||
items.push({ role, text: trimmed });
|
||||
}
|
||||
|
||||
if (items.length <= maxItems) return items;
|
||||
return items.slice(-maxItems);
|
||||
}
|
||||
|
||||
function readRecentMessagesFromTranscript(
|
||||
filePath: string,
|
||||
maxMessages: number,
|
||||
readBytes: number,
|
||||
): TranscriptPreviewMessage[] {
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const stat = fs.fstatSync(fd);
|
||||
const size = stat.size;
|
||||
if (size === 0) return [];
|
||||
|
||||
const readStart = Math.max(0, size - readBytes);
|
||||
const readLen = Math.min(size, readBytes);
|
||||
const buf = Buffer.alloc(readLen);
|
||||
fs.readSync(fd, buf, 0, readLen, readStart);
|
||||
|
||||
const chunk = buf.toString("utf-8");
|
||||
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
|
||||
const tailLines = lines.slice(-PREVIEW_MAX_LINES);
|
||||
|
||||
const collected: TranscriptPreviewMessage[] = [];
|
||||
for (let i = tailLines.length - 1; i >= 0; i--) {
|
||||
const line = tailLines[i];
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptPreviewMessage | undefined;
|
||||
if (msg && typeof msg === "object") {
|
||||
collected.push(msg);
|
||||
if (collected.length >= maxMessages) break;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return collected.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionPreviewItemsFromTranscript(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile: string | undefined,
|
||||
agentId: string | undefined,
|
||||
maxItems: number,
|
||||
maxChars: number,
|
||||
): SessionPreviewItem[] {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return [];
|
||||
|
||||
const boundedItems = Math.max(1, Math.min(maxItems, 50));
|
||||
const boundedChars = Math.max(20, Math.min(maxChars, 2000));
|
||||
|
||||
for (const readSize of PREVIEW_READ_SIZES) {
|
||||
const messages = readRecentMessagesFromTranscript(filePath, boundedItems, readSize);
|
||||
if (messages.length > 0 || readSize === PREVIEW_READ_SIZES[PREVIEW_READ_SIZES.length - 1]) {
|
||||
return buildPreviewItems(messages, boundedItems, boundedChars);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export {
|
||||
capArrayByJsonBytes,
|
||||
readFirstUserMessageFromTranscript,
|
||||
readLastMessagePreviewFromTranscript,
|
||||
readSessionPreviewItemsFromTranscript,
|
||||
readSessionMessages,
|
||||
resolveSessionTranscriptCandidates,
|
||||
} from "./session-utils.fs.js";
|
||||
@@ -47,6 +48,8 @@ export type {
|
||||
GatewaySessionsDefaults,
|
||||
SessionsListResult,
|
||||
SessionsPatchResult,
|
||||
SessionsPreviewEntry,
|
||||
SessionsPreviewResult,
|
||||
} from "./session-utils.types.js";
|
||||
|
||||
const DERIVED_TITLE_MAX_LEN = 60;
|
||||
|
||||
@@ -55,6 +55,22 @@ export type GatewayAgentRow = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SessionPreviewItem = {
|
||||
role: "user" | "assistant" | "tool" | "system" | "other";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type SessionsPreviewEntry = {
|
||||
key: string;
|
||||
status: "ok" | "empty" | "missing" | "error";
|
||||
items: SessionPreviewItem[];
|
||||
};
|
||||
|
||||
export type SessionsPreviewResult = {
|
||||
ts: number;
|
||||
previews: SessionsPreviewEntry[];
|
||||
};
|
||||
|
||||
export type SessionsListResult = {
|
||||
ts: number;
|
||||
path: string;
|
||||
|
||||
@@ -160,11 +160,9 @@ const saveSessionToMemory: HookHandler = async (event) => {
|
||||
await fs.writeFile(memoryFilePath, entry, "utf-8");
|
||||
console.log("[session-memory] Memory file written successfully");
|
||||
|
||||
// Send confirmation message to user with filename
|
||||
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
|
||||
const relPath = memoryFilePath.replace(os.homedir(), "~");
|
||||
const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`;
|
||||
event.messages.push(confirmMsg);
|
||||
console.log("[session-memory] Confirmation message queued:", confirmMsg);
|
||||
console.log(`[session-memory] Session context saved to ${relPath}`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[session-memory] Failed to save session memory:",
|
||||
|
||||
@@ -358,14 +358,20 @@ function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS
|
||||
const candidate = path.resolve(base, expanded);
|
||||
return isExecutableFile(candidate) ? candidate : undefined;
|
||||
}
|
||||
const envPath = env?.PATH ?? process.env.PATH ?? "";
|
||||
const envPath = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
|
||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||
const hasExtension = process.platform === "win32" && path.extname(expanded).length > 0;
|
||||
const extensions =
|
||||
process.platform === "win32"
|
||||
? hasExtension
|
||||
? [""]
|
||||
: (env?.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
||||
: (
|
||||
env?.PATHEXT ??
|
||||
env?.Pathext ??
|
||||
process.env.PATHEXT ??
|
||||
process.env.Pathext ??
|
||||
".EXE;.CMD;.BAT;.COM"
|
||||
)
|
||||
.split(";")
|
||||
.map((ext) => ext.toLowerCase())
|
||||
: [""];
|
||||
@@ -459,6 +465,21 @@ function matchesPattern(pattern: string, target: string): boolean {
|
||||
return regex.test(normalizedTarget);
|
||||
}
|
||||
|
||||
function resolveAllowlistCandidatePath(
|
||||
resolution: CommandResolution | null,
|
||||
cwd?: string,
|
||||
): string | undefined {
|
||||
if (!resolution) return undefined;
|
||||
if (resolution.resolvedPath) return resolution.resolvedPath;
|
||||
const raw = resolution.rawExecutable?.trim();
|
||||
if (!raw) return undefined;
|
||||
const expanded = raw.startsWith("~") ? expandHome(raw) : raw;
|
||||
if (!expanded.includes("/") && !expanded.includes("\\")) return undefined;
|
||||
if (path.isAbsolute(expanded)) return expanded;
|
||||
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
||||
return path.resolve(base, expanded);
|
||||
}
|
||||
|
||||
export function matchAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
resolution: CommandResolution | null,
|
||||
@@ -513,7 +534,7 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === "\\") {
|
||||
if (!inSingle && !inDouble && ch === "\\") {
|
||||
escaped = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
@@ -589,7 +610,7 @@ function tokenizeShellSegment(segment: string): string[] | null {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === "\\") {
|
||||
if (!inSingle && !inDouble && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
@@ -764,7 +785,12 @@ export function evaluateExecAllowlist(params: {
|
||||
}
|
||||
const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0;
|
||||
const allowlistSatisfied = params.analysis.segments.every((segment) => {
|
||||
const match = matchAllowlist(params.allowlist, segment.resolution);
|
||||
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
|
||||
const candidateResolution =
|
||||
candidatePath && segment.resolution
|
||||
? { ...segment.resolution, resolvedPath: candidatePath }
|
||||
: segment.resolution;
|
||||
const match = matchAllowlist(params.allowlist, candidateResolution);
|
||||
if (match) allowlistMatches.push(match);
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
|
||||
@@ -184,9 +184,19 @@ export async function loadCostUsageSummary(params?: {
|
||||
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId);
|
||||
const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
||||
.map((entry) => path.join(sessionsDir, entry.name));
|
||||
const files = (
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(sessionsDir, entry.name);
|
||||
const stats = await fs.promises.stat(filePath).catch(() => null);
|
||||
if (!stats) return null;
|
||||
if (stats.mtimeMs < sinceTime) return null;
|
||||
return filePath;
|
||||
}),
|
||||
)
|
||||
).filter((filePath): filePath is string => Boolean(filePath));
|
||||
|
||||
for (const filePath of files) {
|
||||
await scanUsageFile({
|
||||
|
||||
@@ -94,7 +94,7 @@ async function uploadSlackFile(params: {
|
||||
file: buffer,
|
||||
filename: fileName,
|
||||
...(params.caption ? { initial_comment: params.caption } : {}),
|
||||
...(contentType ? { filetype: contentType } : {}),
|
||||
// Note: filetype is deprecated in files.uploadV2, Slack auto-detects from file content
|
||||
};
|
||||
const payload: FilesUploadV2Arguments = params.threadTs
|
||||
? { ...basePayload, thread_ts: params.threadTs }
|
||||
|
||||
@@ -191,7 +191,7 @@ export async function configureGatewayForOnboarding(
|
||||
const tokenInput = await prompter.text({
|
||||
message: "Gateway token (blank to generate)",
|
||||
placeholder: "Needed for multi-machine or non-loopback access",
|
||||
initialValue: quickstartGateway.token ?? randomToken(),
|
||||
initialValue: quickstartGateway.token ?? "",
|
||||
});
|
||||
gatewayToken = String(tokenInput).trim() || randomToken();
|
||||
}
|
||||
|
||||
@@ -82,10 +82,9 @@ export async function runOnboardingWizard(
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
|
||||
|
||||
if (snapshot.exists) {
|
||||
const title = snapshot.valid ? "Existing config detected" : "Invalid config";
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), title);
|
||||
if (!snapshot.valid && snapshot.issues.length > 0) {
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config");
|
||||
if (snapshot.issues.length > 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`),
|
||||
@@ -95,14 +94,51 @@ export async function runOnboardingWizard(
|
||||
"Config issues",
|
||||
);
|
||||
}
|
||||
await prompter.outro(
|
||||
`Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.valid) {
|
||||
await prompter.outro(
|
||||
`Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
|
||||
const manualHint = "Configure port, network, Tailscale, and auth options.";
|
||||
const explicitFlowRaw = opts.flow?.trim();
|
||||
const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
|
||||
if (
|
||||
normalizedExplicitFlow &&
|
||||
normalizedExplicitFlow !== "quickstart" &&
|
||||
normalizedExplicitFlow !== "advanced"
|
||||
) {
|
||||
runtime.error("Invalid --flow (use quickstart, manual, or advanced).");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const explicitFlow: WizardFlow | undefined =
|
||||
normalizedExplicitFlow === "quickstart" || normalizedExplicitFlow === "advanced"
|
||||
? normalizedExplicitFlow
|
||||
: undefined;
|
||||
let flow: WizardFlow =
|
||||
explicitFlow ??
|
||||
((await prompter.select({
|
||||
message: "Onboarding mode",
|
||||
options: [
|
||||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||||
{ value: "advanced", label: "Manual", hint: manualHint },
|
||||
],
|
||||
initialValue: "quickstart",
|
||||
})) as "quickstart" | "advanced");
|
||||
|
||||
if (opts.mode === "remote" && flow === "quickstart") {
|
||||
await prompter.note(
|
||||
"QuickStart only supports local gateways. Switching to Manual mode.",
|
||||
"QuickStart",
|
||||
);
|
||||
flow = "advanced";
|
||||
}
|
||||
|
||||
if (snapshot.exists) {
|
||||
await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
|
||||
|
||||
const action = (await prompter.select({
|
||||
message: "Config handling",
|
||||
@@ -134,37 +170,6 @@ export async function runOnboardingWizard(
|
||||
}
|
||||
}
|
||||
|
||||
const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
|
||||
const advancedHint = "Configure port, network, Tailscale, and auth options.";
|
||||
const explicitFlowRaw = opts.flow?.trim();
|
||||
if (explicitFlowRaw && explicitFlowRaw !== "quickstart" && explicitFlowRaw !== "advanced") {
|
||||
runtime.error("Invalid --flow (use quickstart or advanced).");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const explicitFlow: WizardFlow | undefined =
|
||||
explicitFlowRaw === "quickstart" || explicitFlowRaw === "advanced"
|
||||
? explicitFlowRaw
|
||||
: undefined;
|
||||
let flow: WizardFlow =
|
||||
explicitFlow ??
|
||||
((await prompter.select({
|
||||
message: "Onboarding mode",
|
||||
options: [
|
||||
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
|
||||
{ value: "advanced", label: "Advanced", hint: advancedHint },
|
||||
],
|
||||
initialValue: "quickstart",
|
||||
})) as "quickstart" | "advanced");
|
||||
|
||||
if (opts.mode === "remote" && flow === "quickstart") {
|
||||
await prompter.note(
|
||||
"QuickStart only supports local gateways. Switching to Advanced mode.",
|
||||
"QuickStart",
|
||||
);
|
||||
flow = "advanced";
|
||||
}
|
||||
|
||||
const quickstartGateway: QuickstartGatewayDefaults = (() => {
|
||||
const hasExisting =
|
||||
typeof baseConfig.gateway?.port === "number" ||
|
||||
|
||||
Reference in New Issue
Block a user