feat: refactor Agent-agnostic types; update Tools and Unified Agent Runner to support latest CCSDK fixes

This commit is contained in:
David Garson 2026-01-28 15:05:33 -07:00
parent aa4418e496
commit 460395baea
53 changed files with 4778 additions and 565 deletions

View File

@ -74,6 +74,27 @@ Session transcripts are stored as JSONL at:
The session ID is stable and chosen by Moltbot.
Legacy Pi/Tau session folders are **not** read.
## Claude Agent SDK Runtime
When using the Claude Agent SDK (`ccsdk`) runtime, Moltbot stores SDK-specific
configuration and sessions in a per-agent `.claude` directory:
```
~/.clawdbot/agents/<agentId>/.claude/
├── sessions/ # SDK native session files
├── settings.json # Per-agent SDK settings (optional)
├── CLAUDE.md # Per-agent memory/instructions (optional)
├── skills/ # Per-agent skills (optional)
└── commands/ # Per-agent slash commands (optional)
```
This separation ensures Moltbot agent sessions are stored separately from
standard Claude Code CLI sessions (`~/.claude/`), and allows per-agent
customization of Claude Code features like settings, skills, and commands.
The SDK config directory is set via the `CLAUDE_CONFIG_DIR` environment variable
when launching the SDK process.
## Steering while streaming
When queue mode is `steer`, inbound messages are injected into the current run.

View File

@ -24,6 +24,7 @@ All session state is **owned by the gateway** (the “master” Moltbot). UI cli
- On the **gateway host**:
- Store file: `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (per agent).
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
- Claude Agent SDK sessions (when using `ccsdk` runtime): `~/.clawdbot/agents/<agentId>/.claude/sessions/` (separate from CLI sessions at `~/.claude/`).
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
- Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.

View File

@ -11,15 +11,25 @@ import type { AgentStreamParams } from "../commands/agent/types.js";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "./bash-tools.js";
import type { BlockReplyChunking, ToolResultFormat } from "./pi-embedded-subscribe.js";
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
import type { SkillSnapshot } from "./skills.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner/types.js";
import type {
AgentRunResult,
AgentRuntimeKind,
AgentSandboxInfo,
ClientToolDefinition,
} from "./runtime-result-types.js";
/** Agent runtime backend discriminant. */
export type AgentRuntimeKind = "pi" | "ccsdk";
// Re-export shared result types for convenience
export type {
AgentRunResult,
AgentRunMeta,
AgentRunResultMeta,
AgentRuntimeKind,
ClientToolDefinition,
} from "./runtime-result-types.js";
/** Result type shared by all agent runtimes. */
export type AgentRuntimeResult = EmbeddedPiRunResult;
export type AgentRuntimeResult = AgentRunResult;
/** Streaming and event callbacks for agent runs. */
export type AgentRuntimeCallbacks = {
@ -59,12 +69,6 @@ export type AgentRuntimeCallbacks = {
export type PiRuntimeOptions = {
/** Whether to enforce final XML tag in responses. */
enforceFinalTag?: boolean;
/** Execution tool overrides (host, security, ask, node). */
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
/** Bash elevated execution defaults. */
bashElevated?: ExecElevatedDefaults;
/** Client-provided tools (OpenResponses hosted tools). */
clientTools?: ClientToolDefinition[];
};
/**
@ -79,8 +83,6 @@ export type CcSdkRuntimeOptions = {
sdkOptions?: Record<string, unknown>;
/** 3-tier model configuration for Claude Code SDK. */
modelTiers?: CcSdkModelTiers;
/** Existing Claude Code session ID for resumption. */
claudeSessionId?: string;
/** Fork the session instead of continuing it. */
forkSession?: boolean;
};
@ -174,6 +176,16 @@ export type AgentRuntimeRunParams = {
shouldEmitToolOutput?: () => boolean;
/** Owner phone numbers for access control. */
ownerNumbers?: string[];
/** Provider session ID for session resumption (used by CLI providers and CCSDK). */
providerSessionId?: string;
/** Sandbox/workspace configuration for agent runs. */
sandboxInfo?: AgentSandboxInfo;
/** Bash/exec elevated execution defaults. Controls where exec commands run (sandbox vs host). */
bashElevated?: ExecElevatedDefaults;
/** Exec tool overrides (host, security, ask, node). Shared by both runtimes. */
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
/** Client-provided tools (OpenResponses hosted tools). Applicable to both runtimes. */
clientTools?: ClientToolDefinition[];
// ─── Runtime-specific option bags ───────────────────────────────────────────
/** Pi runtime-specific options. */

View File

@ -0,0 +1,76 @@
/**
* Bridge for converting client-provided tools (OpenResponses hosted tools)
* to the AnyAgentTool format used by the Claude Agent SDK runtime.
*
* These tools return "pending" results when called - execution is delegated
* back to the client that provided the tool definitions.
*/
import type { ClientToolDefinition } from "../runtime-result-types.js";
import type { AnyAgentTool } from "../tools/common.js";
import { jsonResult } from "../tools/common.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("agents/claude-agent-sdk/client-tool-bridge");
/**
* Callback type for when a client tool is invoked.
* Allows callers to track which tools were called and with what parameters.
*/
export type OnClientToolCallCallback = (toolName: string, params: Record<string, unknown>) => void;
/**
* Convert ClientToolDefinition array to AnyAgentTool array for CCSDK.
*
* These tools return "pending" results indicating that execution should be
* delegated to the client. This matches the behavior in the Pi runtime's
* toClientToolDefinitions() function.
*
* @param clientTools - Array of client-provided tool definitions
* @param onClientToolCall - Optional callback invoked when a client tool is called
* @returns Array of AnyAgentTool compatible with the CCSDK tool bridge
*/
export function convertClientToolsForSdk(
clientTools: ClientToolDefinition[],
onClientToolCall?: OnClientToolCallCallback,
): AnyAgentTool[] {
return clientTools.map((tool) => {
const func = tool.function;
const toolName = func.name;
log.debug(`Converting client tool for CCSDK: ${toolName}`);
return {
name: toolName,
label: toolName,
description: func.description ?? "",
// Pass through the JSON Schema parameters directly
// The tool-bridge will serialize this for MCP
parameters: func.parameters ?? { type: "object", properties: {} },
execute: async (
_toolCallId: string,
params: Record<string, unknown>,
_signal?: AbortSignal,
_onUpdate?: unknown,
) => {
log.debug(`Client tool "${toolName}" called with params`, {
paramKeys: Object.keys(params),
});
// Notify callback that a client tool was invoked
if (onClientToolCall) {
onClientToolCall(toolName, params);
}
// Return a pending result - the client will handle actual execution
// This matches the behavior of toClientToolDefinitions in pi-tool-definition-adapter.ts
return jsonResult({
status: "pending",
tool: toolName,
message: "Tool execution delegated to client",
});
},
} as AnyAgentTool;
});
}

View File

@ -0,0 +1,493 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock dependencies
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
import {
classifyError,
isRetryableError,
describeErrorKind,
withRetry,
CcsdkError,
isCcsdkError,
toCcsdkError,
DEFAULT_RETRY_OPTIONS,
} from "./error-handling.js";
import type { RetryOptions } from "./error-handling.js";
describe("error-handling", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.resetAllMocks();
});
describe("classifyError", () => {
describe("rate limit errors", () => {
it("classifies HTTP 429 status as rate_limit", () => {
expect(classifyError({ status: 429 })).toBe("rate_limit");
});
it("classifies rate limit message patterns", () => {
expect(classifyError({ message: "Rate limit exceeded" })).toBe("rate_limit");
expect(classifyError({ message: "Too many requests" })).toBe("rate_limit");
expect(classifyError({ message: "Resource has been exhausted" })).toBe("rate_limit");
expect(classifyError({ message: "Server overloaded" })).toBe("rate_limit");
expect(classifyError({ message: "Request throttled" })).toBe("rate_limit");
});
});
describe("context overflow errors", () => {
it("classifies context window errors", () => {
expect(classifyError({ message: "Context window exceeded" })).toBe("context_overflow");
expect(classifyError({ message: "context length too long" })).toBe("context_overflow");
expect(classifyError({ message: "Context overflow error" })).toBe("context_overflow");
});
it("classifies token limit errors", () => {
expect(classifyError({ message: "Exceeds maximum token limit" })).toBe("context_overflow");
expect(classifyError({ message: "Maximum context size reached" })).toBe("context_overflow");
expect(classifyError({ message: "Prompt too long for model" })).toBe("context_overflow");
});
});
describe("auth failure errors", () => {
it("classifies HTTP 401 and 403 as auth_failure", () => {
expect(classifyError({ status: 401 })).toBe("auth_failure");
expect(classifyError({ status: 403 })).toBe("auth_failure");
});
it("classifies authentication message patterns", () => {
expect(classifyError({ message: "Authentication failed" })).toBe("auth_failure");
expect(classifyError({ message: "Authorization error" })).toBe("auth_failure");
expect(classifyError({ message: "Invalid API key" })).toBe("auth_failure");
expect(classifyError({ message: "Invalid token provided" })).toBe("auth_failure");
expect(classifyError({ message: "Permission denied" })).toBe("auth_failure");
expect(classifyError({ message: "Unauthorized access" })).toBe("auth_failure");
expect(classifyError({ message: "Forbidden" })).toBe("auth_failure");
});
});
describe("network errors", () => {
it("classifies network error codes", () => {
expect(classifyError({ code: "ECONNREFUSED" })).toBe("network");
expect(classifyError({ code: "ECONNRESET" })).toBe("network");
expect(classifyError({ code: "ENOTFOUND" })).toBe("network");
});
it("classifies network message patterns", () => {
expect(classifyError({ message: "Network error" })).toBe("network");
expect(classifyError({ message: "Connection refused" })).toBe("network");
expect(classifyError({ message: "connect failed" })).toBe("network");
expect(classifyError({ message: "Socket error" })).toBe("network");
expect(classifyError({ message: "Fetch failed" })).toBe("network");
});
});
describe("timeout errors", () => {
it("classifies HTTP 408 as timeout", () => {
expect(classifyError({ status: 408 })).toBe("timeout");
});
it("classifies timeout error codes", () => {
expect(classifyError({ code: "ETIMEDOUT" })).toBe("timeout");
expect(classifyError({ code: "ESOCKETTIMEDOUT" })).toBe("timeout");
});
it("classifies TimeoutError by name", () => {
const error = new Error("Request timed out");
error.name = "TimeoutError";
expect(classifyError(error)).toBe("timeout");
});
it("classifies AbortError as timeout", () => {
const error = new Error("Request aborted");
error.name = "AbortError";
expect(classifyError(error)).toBe("timeout");
});
it("classifies timeout message patterns", () => {
expect(classifyError({ message: "Request timed out" })).toBe("timeout");
expect(classifyError({ message: "Timeout error" })).toBe("timeout");
expect(classifyError({ message: "Deadline exceeded" })).toBe("timeout");
expect(classifyError({ message: "Request was aborted" })).toBe("timeout");
});
});
describe("tool errors", () => {
it("classifies tool execution errors", () => {
expect(classifyError({ message: "Tool error occurred" })).toBe("tool_error");
expect(classifyError({ message: "Tool execution failed" })).toBe("tool_error");
expect(classifyError({ message: "MCP error" })).toBe("tool_error");
expect(classifyError({ message: "MCP tool failed" })).toBe("tool_error");
});
});
describe("unknown errors", () => {
it("returns unknown for null/undefined", () => {
expect(classifyError(null)).toBe("unknown");
expect(classifyError(undefined)).toBe("unknown");
});
it("returns unknown for unrecognized errors", () => {
expect(classifyError({ message: "Something unexpected happened" })).toBe("unknown");
expect(classifyError(new Error("Generic error"))).toBe("unknown");
});
it("returns unknown for empty error objects", () => {
expect(classifyError({})).toBe("unknown");
});
});
describe("edge cases", () => {
it("handles errors with both status and message", () => {
// Status takes precedence
expect(classifyError({ status: 429, message: "Connection error" })).toBe("rate_limit");
});
it("handles string errors", () => {
expect(classifyError("Rate limit exceeded")).toBe("rate_limit");
});
it("handles numeric status as string", () => {
expect(classifyError({ status: "429" })).toBe("rate_limit");
});
it("handles errors with code field", () => {
expect(classifyError({ code: "ETIMEDOUT", message: "unknown" })).toBe("timeout");
});
});
});
describe("isRetryableError", () => {
it("returns true for rate_limit errors by default", () => {
expect(isRetryableError({ status: 429 })).toBe(true);
});
it("returns true for network errors by default", () => {
expect(isRetryableError({ code: "ECONNRESET" })).toBe(true);
});
it("returns true for timeout errors by default", () => {
expect(isRetryableError({ status: 408 })).toBe(true);
});
it("returns false for auth errors by default", () => {
expect(isRetryableError({ status: 401 })).toBe(false);
});
it("returns false for context overflow by default", () => {
expect(isRetryableError({ message: "Context window exceeded" })).toBe(false);
});
it("respects custom retryable kinds list", () => {
expect(isRetryableError({ status: 401 }, ["auth_failure"])).toBe(true);
expect(isRetryableError({ status: 429 }, ["auth_failure"])).toBe(false);
});
});
describe("describeErrorKind", () => {
it("returns human-readable descriptions", () => {
expect(describeErrorKind("rate_limit")).toBe("Rate limit exceeded");
expect(describeErrorKind("context_overflow")).toBe("Context window overflow");
expect(describeErrorKind("auth_failure")).toBe("Authentication failed");
expect(describeErrorKind("network")).toBe("Network error");
expect(describeErrorKind("timeout")).toBe("Request timed out");
expect(describeErrorKind("tool_error")).toBe("Tool execution error");
expect(describeErrorKind("unknown")).toBe("Unknown error");
});
});
describe("withRetry", () => {
it("returns result on first successful attempt", async () => {
const fn = vi.fn().mockResolvedValue("success");
const options: RetryOptions = {
maxRetries: 3,
backoffMs: 100,
retryOn: ["rate_limit"],
};
const result = await withRetry(fn, options);
expect(result).toBe("success");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries on retryable errors", async () => {
const rateLimitError = { status: 429, message: "Rate limited" };
const fn = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValue("success");
const options: RetryOptions = {
maxRetries: 3,
backoffMs: 100,
retryOn: ["rate_limit"],
};
const promise = withRetry(fn, options);
// Advance timers for retries
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe("success");
expect(fn).toHaveBeenCalledTimes(3);
});
it("throws immediately on non-retryable errors", async () => {
const authError = { status: 401, message: "Unauthorized" };
const fn = vi.fn().mockRejectedValue(authError);
const options: RetryOptions = {
maxRetries: 3,
backoffMs: 100,
retryOn: ["rate_limit"],
};
await expect(withRetry(fn, options)).rejects.toMatchObject({ status: 401 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("throws after exhausting retries", async () => {
const rateLimitError = { status: 429, message: "Rate limited" };
const fn = vi.fn().mockRejectedValue(rateLimitError);
const options: RetryOptions = {
maxRetries: 2,
backoffMs: 100,
retryOn: ["rate_limit"],
};
const promise = withRetry(fn, options);
// Advance timers for all retries
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(200);
await expect(promise).rejects.toMatchObject({ status: 429 });
expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
});
it("respects abort signal", async () => {
const fn = vi.fn().mockRejectedValue({ status: 429 });
const controller = new AbortController();
const options: RetryOptions = {
maxRetries: 3,
backoffMs: 100,
retryOn: ["rate_limit"],
abortSignal: controller.signal,
};
controller.abort();
await expect(withRetry(fn, options)).rejects.toThrow("Aborted");
});
it("calls onRetry callback for each retry", async () => {
const rateLimitError = { status: 429, message: "Rate limited" };
const fn = vi.fn().mockRejectedValueOnce(rateLimitError).mockResolvedValue("success");
const onRetry = vi.fn();
const options: RetryOptions = {
maxRetries: 3,
backoffMs: 100,
retryOn: ["rate_limit"],
onRetry,
};
const promise = withRetry(fn, options);
await vi.advanceTimersByTimeAsync(200);
await promise;
expect(onRetry).toHaveBeenCalledWith(
1, // attempt number
rateLimitError,
expect.any(Number), // delay
);
});
it("applies exponential backoff", async () => {
const rateLimitError = { status: 429, message: "Rate limited" };
const fn = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValue("success");
const delays: number[] = [];
const options: RetryOptions = {
maxRetries: 3,
backoffMs: 100,
backoffMultiplier: 2,
retryOn: ["rate_limit"],
onRetry: (_attempt, _error, delay) => {
delays.push(delay);
},
};
const promise = withRetry(fn, options);
// Need to advance through both retry waits
await vi.advanceTimersByTimeAsync(500);
await promise;
// Delays should increase exponentially (with some jitter)
expect(delays[0]).toBeGreaterThanOrEqual(100);
expect(delays[0]).toBeLessThanOrEqual(120); // 100 + 20% jitter
expect(delays[1]).toBeGreaterThanOrEqual(200);
expect(delays[1]).toBeLessThanOrEqual(240); // 200 + 20% jitter
});
it("respects maxBackoffMs", async () => {
const rateLimitError = { status: 429, message: "Rate limited" };
const fn = vi
.fn()
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValue("success");
const delays: number[] = [];
const options: RetryOptions = {
maxRetries: 4,
backoffMs: 1000,
maxBackoffMs: 1500,
backoffMultiplier: 10,
retryOn: ["rate_limit"],
onRetry: (_attempt, _error, delay) => {
delays.push(delay);
},
};
const promise = withRetry(fn, options);
await vi.advanceTimersByTimeAsync(10000);
await promise;
// All delays should be capped at maxBackoffMs (+ jitter)
for (const delay of delays) {
expect(delay).toBeLessThanOrEqual(1500 * 1.2);
}
});
});
describe("CcsdkError", () => {
it("creates error with kind and message", () => {
const error = new CcsdkError("Rate limited", "rate_limit");
expect(error.message).toBe("Rate limited");
expect(error.kind).toBe("rate_limit");
expect(error.name).toBe("CcsdkError");
});
it("includes optional status and code", () => {
const error = new CcsdkError("Auth failed", "auth_failure", {
status: 401,
code: "AUTH_ERROR",
});
expect(error.status).toBe(401);
expect(error.code).toBe("AUTH_ERROR");
});
it("chains cause from original error", () => {
const original = new Error("Original error");
const error = new CcsdkError("Wrapped error", "unknown", {
cause: original,
});
// Access cause via ES2022 Error.cause property (cast to any for TypeScript)
expect((error as unknown as { cause: unknown }).cause).toBe(original);
});
});
describe("isCcsdkError", () => {
it("returns true for CcsdkError instances", () => {
const error = new CcsdkError("Test", "unknown");
expect(isCcsdkError(error)).toBe(true);
});
it("returns false for regular errors", () => {
const error = new Error("Test");
expect(isCcsdkError(error)).toBe(false);
});
it("returns false for non-errors", () => {
expect(isCcsdkError(null)).toBe(false);
expect(isCcsdkError({ message: "not an error" })).toBe(false);
});
});
describe("toCcsdkError", () => {
it("returns same error if already CcsdkError", () => {
const error = new CcsdkError("Already wrapped", "rate_limit");
expect(toCcsdkError(error)).toBe(error);
});
it("converts regular error to CcsdkError", () => {
const error = new Error("Rate limit exceeded");
const converted = toCcsdkError(error);
expect(converted).toBeInstanceOf(CcsdkError);
expect(converted.message).toBe("Rate limit exceeded");
expect(converted.kind).toBe("rate_limit");
});
it("extracts status and code from error", () => {
const error = Object.assign(new Error("Not found"), {
status: 404,
code: "NOT_FOUND",
});
const converted = toCcsdkError(error);
expect(converted.status).toBe(404);
expect(converted.code).toBe("NOT_FOUND");
});
it("uses kind description for empty message", () => {
const converted = toCcsdkError({ status: 429 });
expect(converted.message).toBe("Rate limit exceeded");
expect(converted.kind).toBe("rate_limit");
});
it("chains original error as cause", () => {
const original = new Error("Original");
const converted = toCcsdkError(original);
// Access cause via ES2022 Error.cause property (cast to any for TypeScript)
expect((converted as unknown as { cause: unknown }).cause).toBe(original);
});
});
describe("DEFAULT_RETRY_OPTIONS", () => {
it("has sensible defaults", () => {
expect(DEFAULT_RETRY_OPTIONS.maxRetries).toBe(3);
expect(DEFAULT_RETRY_OPTIONS.backoffMs).toBe(1000);
expect(DEFAULT_RETRY_OPTIONS.maxBackoffMs).toBe(30_000);
expect(DEFAULT_RETRY_OPTIONS.backoffMultiplier).toBe(2);
expect(DEFAULT_RETRY_OPTIONS.retryOn).toEqual(["rate_limit", "network", "timeout"]);
});
});
});

View File

@ -2,16 +2,20 @@
* Provider configuration builders for the Claude Agent SDK.
*
* Supports multiple authentication methods:
* - Anthropic API key (ANTHROPIC_API_KEY)
* - Claude Code CLI OAuth (reuses ~/.claude credentials)
* - z.AI subscription (via ANTHROPIC_AUTH_TOKEN or CLI auth)
* - Anthropic API key (ANTHROPIC_API_KEY) - when explicitly configured in moltbot.json
* - Claude Code SDK native auth (default) - inherits parent env, SDK handles credential resolution
* - z.AI subscription (via ANTHROPIC_AUTH_TOKEN)
* - OpenRouter (Anthropic-compatible API)
* - AWS Bedrock
* - Google Vertex AI
*
* For SDK native auth, we inherit the full parent process environment and let the SDK
* handle credential resolution. If the user has ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN
* set in their environment, the SDK will use them. Otherwise, it uses its native
* keychain-based OAuth flow.
*/
import type { SdkProviderConfig, SdkProviderEnv } from "./types.js";
import { readClaudeCliCredentialsCached } from "../cli-credentials.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("agents/claude-agent-sdk");
@ -67,73 +71,25 @@ export function buildAnthropicSdkProvider(apiKey: string): SdkProviderConfig {
}
/**
* Build provider config for Claude Code CLI/subscription auth.
* Build provider config for Claude Code SDK native auth.
*
* This method reads credentials from the Claude Code CLI's keychain/file storage,
* enabling subscription-based access (Claude Max, z.AI, etc.) without an API key.
* This returns a minimal config with no env overrides, allowing the SDK to:
* - Inherit any ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN from parent process
* - Use its internal credential resolution (keychain OAuth) if no env vars are set
*
* The SDK will automatically use these credentials when they're available in the
* environment or via the CLI's native auth mechanism.
* We don't try to be "smart" about unsetting env vars - if the user has auth
* env vars set in their system environment, that's their configuration choice.
*/
export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called");
export function buildClaudeCliSdkProvider(): SdkProviderConfig {
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called - using SDK native auth");
// Read cached credentials from Claude Code CLI
const credentials = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
ttlMs: 60_000, // 1 minute cache
});
const config: SdkProviderConfig = {
name: "Claude CLI (SDK native)",
env: {},
};
if (!credentials) {
log.debug("[CCSDK-PROVIDER] No Claude CLI credentials found from cache");
return null;
}
log.debug("[CCSDK-PROVIDER] Claude CLI credentials loaded", {
type: credentials.type,
provider: credentials.provider,
expiresAt: new Date(credentials.expires).toISOString(),
isExpired: credentials.expires < Date.now(),
msUntilExpiry: credentials.expires - Date.now(),
});
// For OAuth credentials, use the access token as ANTHROPIC_AUTH_TOKEN
if (credentials.type === "oauth") {
// Check if token is expired
if (credentials.expires < Date.now()) {
log.warn("[CCSDK-PROVIDER] Claude CLI credentials EXPIRED", {
expiresAt: new Date(credentials.expires).toISOString(),
expiredAgo: Date.now() - credentials.expires,
});
// Still return the config - the SDK may handle refresh internally
}
const config: SdkProviderConfig = {
name: "Claude CLI (subscription)",
env: {
ANTHROPIC_AUTH_TOKEN: credentials.access,
},
};
logProviderConfig(config, "claude-cli-oauth");
return config;
}
// For token-type credentials (less common)
if (credentials.type === "token") {
const config: SdkProviderConfig = {
name: "Claude CLI (token)",
env: {
ANTHROPIC_AUTH_TOKEN: credentials.token,
},
};
logProviderConfig(config, "claude-cli-token");
return config;
}
log.debug("[CCSDK-PROVIDER] Unknown credential type", {
type: (credentials as { type: string }).type,
});
return null;
logProviderConfig(config, "sdk-native-auth");
return config;
}
/**
@ -278,9 +234,14 @@ export function buildVertexSdkProvider(): SdkProviderConfig {
* Resolve provider configuration based on available credentials.
*
* Priority order:
* 1. Explicit API key from config/env
* 2. Claude Code CLI credentials (subscription auth)
* 3. Environment variables (fallback)
* 1. Explicit API key from moltbot config (options.apiKey)
* 2. Explicit auth token from moltbot config (options.authToken) - for z.AI, etc.
* 3. SDK native auth (default) - inherits parent env, SDK handles credential resolution
*
* The SDK will inherit the full parent process environment. If auth env vars
* (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN) are set in the parent process,
* the SDK will use them. Otherwise, it falls back to its native credential
* resolution (keychain OAuth).
*/
export function resolveProviderConfig(options?: {
apiKey?: string;
@ -297,9 +258,9 @@ export function resolveProviderConfig(options?: {
useCliCredentials: options?.useCliCredentials ?? true,
});
// 1. Explicit API key takes precedence
// 1. Explicit API key from moltbot config takes precedence
if (options?.apiKey) {
log.debug("[CCSDK-PROVIDER] Using explicit API key");
log.debug("[CCSDK-PROVIDER] Using explicit API key from moltbot config");
const config = buildAnthropicSdkProvider(options.apiKey);
if (options.baseUrl && config.env) {
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
@ -308,9 +269,9 @@ export function resolveProviderConfig(options?: {
return config;
}
// 2. Explicit auth token (OAuth/subscription)
// 2. Explicit auth token from moltbot config (for z.AI, custom endpoints, etc.)
if (options?.authToken) {
log.debug("[CCSDK-PROVIDER] Using explicit auth token");
log.debug("[CCSDK-PROVIDER] Using explicit auth token from moltbot config");
const env: SdkProviderEnv = {
ANTHROPIC_AUTH_TOKEN: options.authToken,
};
@ -325,67 +286,14 @@ export function resolveProviderConfig(options?: {
return config;
}
// 3. Try Claude CLI credentials if enabled (default: true)
if (options?.useCliCredentials !== false) {
log.debug("[CCSDK-PROVIDER] Attempting Claude CLI credentials");
const cliConfig = buildClaudeCliSdkProvider();
if (cliConfig) {
log.debug("[CCSDK-PROVIDER] Using Claude CLI credentials", {
providerName: cliConfig.name,
});
if (options?.baseUrl && cliConfig.env) {
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
}
return cliConfig;
}
log.debug("[CCSDK-PROVIDER] Claude CLI credentials not available");
// 3. Default: SDK native auth
// Let the SDK use its internal credential resolution (keychain → OAuth flow).
// We inherit the full parent process env - if the user has ANTHROPIC_API_KEY or
// ANTHROPIC_AUTH_TOKEN set, that's their configuration choice.
log.debug("[CCSDK-PROVIDER] Using SDK native auth (no explicit credentials configured)");
const cliConfig = buildClaudeCliSdkProvider();
if (options?.baseUrl && cliConfig.env) {
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
}
// 4. Check environment variables
const envApiKey = process.env.ANTHROPIC_API_KEY;
const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
log.debug("[CCSDK-PROVIDER] Checking environment variables", {
hasEnvApiKey: Boolean(envApiKey),
envApiKeyMasked: maskToken(envApiKey),
hasEnvAuthToken: Boolean(envAuthToken),
envAuthTokenMasked: maskToken(envAuthToken),
hasEnvBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL),
});
if (envApiKey) {
const config: SdkProviderConfig = {
name: "Anthropic (env)",
env: {
ANTHROPIC_API_KEY: envApiKey,
ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL,
},
};
logProviderConfig(config, "env-api-key");
return config;
}
if (envAuthToken) {
const config: SdkProviderConfig = {
name: "Anthropic (env auth token)",
env: {
ANTHROPIC_AUTH_TOKEN: envAuthToken,
ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL,
},
};
logProviderConfig(config, "env-auth-token");
return config;
}
// 5. Return empty config - SDK will use its own credential resolution
log.warn("[CCSDK-PROVIDER] No credentials found - SDK will use native auth (may fail)", {
triedCliCredentials: options?.useCliCredentials !== false,
triedEnvVars: true,
});
const config: SdkProviderConfig = {
name: "Anthropic (SDK native)",
env: {},
};
logProviderConfig(config, "sdk-native-fallback");
return config;
return cliConfig;
}

View File

@ -24,6 +24,8 @@ import type { SdkConversationTurn, SdkRunnerResult } from "./types.js";
import { createMoltbotCodingTools } from "../pi-tools.js";
import { resolveMoltbotAgentDir } from "../agent-paths.js";
import { resolveModelAuthMode } from "../model-auth.js";
import { resolveCcSdkConfigDirForAgent } from "../../config/sessions/paths.js";
import { convertClientToolsForSdk } from "./client-tool-bridge.js";
const log = createSubsystemLogger("agents/ccsdk-runtime");
@ -81,6 +83,31 @@ function mapVerboseLevel(verboseLevel?: VerboseLevel): SdkVerboseLevel {
}
}
/**
* Map elevated permission level to CCSDK permission mode.
*
* Translates the platform's elevated permission level (from sandboxInfo)
* to the Claude Code SDK's permission mode for file edits and other operations.
*
* Note: This is distinct from bashElevated which controls where exec commands run.
* The CCSDK permissionMode controls Claude Code's built-in permission system.
*/
function mapElevatedToPermissionMode(
elevatedLevel?: "on" | "off" | "ask" | "full",
): string | undefined {
switch (elevatedLevel) {
case "off":
case "ask":
return "default";
case "on":
return "acceptEdits";
case "full":
return "bypassPermissions";
default:
return undefined;
}
}
/**
* Extract agent ID from session key.
*/
@ -135,9 +162,11 @@ function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntim
durationMs: result.meta.durationMs,
aborted: result.meta.aborted,
agentMeta: {
sessionId,
provider: result.meta.provider ?? "sdk",
// Use CCSDK session ID if available (for resume), otherwise Moltbot session ID
sessionId: result.claudeSessionId ?? sessionId,
provider: result.meta.provider ?? "anthropic",
model: result.meta.model ?? "default",
runtime: "ccsdk",
usage,
},
error: undefined,
@ -206,9 +235,14 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
// Build tools for this run (similar to how Pi runtime builds tools in attempt.ts)
// If pre-built tools are provided via context, use those instead
const tools: AnyAgentTool[] =
const builtInTools: AnyAgentTool[] =
context?.tools ??
createMoltbotCodingTools({
// Exec tool configuration (same as Pi runtime)
exec: {
...params.execOverrides,
elevated: params.bashElevated,
},
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
@ -226,7 +260,31 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
groupId: params.groupId,
});
log.debug("Built tools for CCSDK run", { toolCount: tools.length });
// Track client tool calls for OpenResponses integration
// Note: clientToolCallDetected is set when a client tool is invoked; future work may
// expose this in SdkRunnerResult similar to how Pi runtime exposes it in AttemptResult.
let clientToolCallDetected: { name: string; params: Record<string, unknown> } | null = null;
void clientToolCallDetected; // Suppress unused warning (callback mutates this)
// Convert client tools (OpenResponses hosted tools) for CCSDK
const clientToolsConverted = params.clientTools
? convertClientToolsForSdk(params.clientTools, (toolName, toolParams) => {
clientToolCallDetected = { name: toolName, params: toolParams };
})
: [];
// Combine built-in tools with client tools
const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted];
log.debug("Built tools for CCSDK run", {
builtInCount: builtInTools.length,
clientToolCount: clientToolsConverted.length,
totalCount: tools.length,
});
// Resolve CCSDK config directory for this agent's session storage.
// This ensures Moltbot sessions are stored separately from CLI sessions.
const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId);
const sdkResult = await runSdkAgent({
// ─── Core shared params (spread) ────────────────────────────────────────
@ -242,6 +300,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
timeoutMs: params.timeoutMs,
abortSignal: params.abortSignal,
extraSystemPrompt: params.extraSystemPrompt,
ccsdkConfigDir,
// ─── Messaging & sender context (shared) ────────────────────────────────
messageChannel: params.messageChannel,
@ -262,8 +321,10 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled,
sdkOptions: ccsdkOpts.sdkOptions ?? context?.ccsdkConfig?.options,
modelTiers: ccsdkOpts.modelTiers ?? context?.ccsdkConfig?.models,
claudeSessionId: ccsdkOpts.claudeSessionId,
claudeSessionId: params.providerSessionId,
forkSession: ccsdkOpts.forkSession,
// Map sandbox elevated permission level to CCSDK permission mode
permissionMode: mapElevatedToPermissionMode(params.sandboxInfo?.elevated?.defaultLevel),
thinkingLevel: params.thinkLevel,
conversationHistory,
tools,

View File

@ -0,0 +1,409 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { buildMoltbotSdkHooks } from "./sdk-hooks.js";
describe("sdk-hooks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("buildMoltbotSdkHooks", () => {
it("returns hooks config with all expected hook types", () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
expect(hooks.PreToolUse).toBeDefined();
expect(hooks.PostToolUse).toBeDefined();
expect(hooks.PostToolUseFailure).toBeDefined();
expect(hooks.Notification).toBeDefined();
expect(hooks.SessionStart).toBeDefined();
expect(hooks.SessionEnd).toBeDefined();
expect(hooks.UserPromptSubmit).toBeDefined();
expect(hooks.Stop).toBeDefined();
expect(hooks.SubagentStart).toBeDefined();
expect(hooks.SubagentStop).toBeDefined();
expect(hooks.PreCompact).toBeDefined();
});
it("returns hooks with correct structure (array of matchers with hooks)", () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
// Each hook type should be an array of callback matchers
expect(Array.isArray(hooks.PreToolUse)).toBe(true);
expect(hooks.PreToolUse![0]).toHaveProperty("hooks");
expect(Array.isArray(hooks.PreToolUse![0].hooks)).toBe(true);
expect(typeof hooks.PreToolUse![0].hooks[0]).toBe("function");
});
describe("PreToolUse hook (toolStartHook)", () => {
it("emits hook event with correct data", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
await toolStartHook(
{ tool_name: "mcp__moltbot__bash", tool_input: { command: "ls" } },
"tool-use-123",
{},
);
expect(emitEvent).toHaveBeenCalledWith(
"hook",
expect.objectContaining({
hookEventName: "PreToolUse",
toolUseId: "tool-use-123",
}),
);
});
it("emits tool event with normalized name", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
await toolStartHook(
{ tool_name: "mcp__moltbot__bash_exec", tool_input: { cmd: "pwd" } },
"use-456",
{},
);
// Should emit a tool event with phase: start
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent).toBeDefined();
expect(toolEvent![1]).toMatchObject({
phase: "start",
name: expect.any(String),
toolCallId: "use-456",
});
});
it("strips MCP server prefix from tool name", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
await toolStartHook({ tool_name: "mcp__moltbot__some_tool" }, "id-1", {});
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent![1].name).not.toContain("mcp__");
});
it("returns empty object (non-blocking)", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
const result = await toolStartHook({ tool_name: "test" }, "id", {});
expect(result).toEqual({});
});
it("handles missing tool_name gracefully", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
// Should not throw
await expect(toolStartHook({}, "id", {})).resolves.toEqual({});
});
});
describe("PostToolUse hook (toolResultHook)", () => {
it("emits tool event with result", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolResultHook = hooks.PostToolUse![0].hooks[0];
await toolResultHook(
{
tool_name: "mcp__moltbot__bash",
tool_response: "command output here",
},
"tool-use-789",
{},
);
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent).toBeDefined();
expect(toolEvent![1]).toMatchObject({
phase: "result",
isError: false,
toolCallId: "tool-use-789",
});
});
it("calls onToolResult callback with result text", async () => {
const emitEvent = vi.fn();
const onToolResult = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
onToolResult,
});
const toolResultHook = hooks.PostToolUse![0].hooks[0];
await toolResultHook(
{
tool_name: "test_tool",
tool_response: "Tool completed successfully",
},
"id",
{},
);
expect(onToolResult).toHaveBeenCalledWith({
text: expect.stringContaining("Tool completed"),
});
});
it("does not call onToolResult when result is empty", async () => {
const emitEvent = vi.fn();
const onToolResult = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
onToolResult,
});
const toolResultHook = hooks.PostToolUse![0].hooks[0];
await toolResultHook({ tool_name: "test_tool", tool_response: "" }, "id", {});
expect(onToolResult).not.toHaveBeenCalled();
});
it("catches and ignores onToolResult callback errors", async () => {
const emitEvent = vi.fn();
const onToolResult = vi.fn().mockRejectedValue(new Error("Callback failed"));
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
onToolResult,
});
const toolResultHook = hooks.PostToolUse![0].hooks[0];
// Should not throw
await expect(
toolResultHook({ tool_name: "test", tool_response: "result" }, "id", {}),
).resolves.toEqual({});
});
});
describe("PostToolUseFailure hook (toolFailureHook)", () => {
it("emits tool event with isError true", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolFailureHook = hooks.PostToolUseFailure![0].hooks[0];
await toolFailureHook(
{
tool_name: "mcp__moltbot__bash",
error: "Command failed with exit code 1",
},
"tool-use-error",
{},
);
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent).toBeDefined();
expect(toolEvent![1]).toMatchObject({
phase: "result",
isError: true,
toolCallId: "tool-use-error",
});
});
it("calls onToolResult with error text", async () => {
const emitEvent = vi.fn();
const onToolResult = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
onToolResult,
});
const toolFailureHook = hooks.PostToolUseFailure![0].hooks[0];
await toolFailureHook({ tool_name: "test_tool", error: "Something went wrong" }, "id", {});
expect(onToolResult).toHaveBeenCalledWith({
text: expect.stringContaining("Something went wrong"),
});
});
it("catches and ignores onToolResult callback errors", async () => {
const emitEvent = vi.fn();
const onToolResult = vi.fn().mockRejectedValue(new Error("Callback error"));
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
onToolResult,
});
const toolFailureHook = hooks.PostToolUseFailure![0].hooks[0];
await expect(
toolFailureHook({ tool_name: "test", error: "fail" }, "id", {}),
).resolves.toEqual({});
});
});
describe("passthrough hooks", () => {
const passthroughHookNames = [
"Notification",
"SessionStart",
"SessionEnd",
"UserPromptSubmit",
"Stop",
"SubagentStart",
"SubagentStop",
"PreCompact",
] as const;
for (const hookName of passthroughHookNames) {
it(`${hookName} emits hook event`, async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const hook = hooks[hookName]![0].hooks[0];
await hook({ data: "test" }, "use-id", { session_id: "session" });
expect(emitEvent).toHaveBeenCalledWith(
"hook",
expect.objectContaining({
hookEventName: hookName,
}),
);
});
it(`${hookName} returns empty object`, async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const hook = hooks[hookName]![0].hooks[0];
const result = await hook({}, "id", {});
expect(result).toEqual({});
});
}
});
describe("tool name normalization", () => {
it("removes mcp__serverName__ prefix", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "custom-server",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
await toolStartHook({ tool_name: "mcp__custom-server__my_tool" }, "id", {});
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent![1].name).toBe("my_tool");
});
it("handles tools without MCP prefix", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
await toolStartHook({ tool_name: "plain_tool" }, "id", {});
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent![1].name).toBe("plain_tool");
});
it("handles empty tool name", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const toolStartHook = hooks.PreToolUse![0].hooks[0];
await toolStartHook({ tool_name: "" }, "id", {});
const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool");
expect(toolEvent![1].name).toBe("tool"); // fallback
});
});
describe("input sanitization", () => {
it("wraps non-record input in object", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const hook = hooks.Notification![0].hooks[0];
await hook("string input", "id", {});
expect(emitEvent).toHaveBeenCalledWith(
"hook",
expect.objectContaining({
input: "string input",
}),
);
});
it("spreads record input into event", async () => {
const emitEvent = vi.fn();
const hooks = buildMoltbotSdkHooks({
mcpServerName: "moltbot",
emitEvent,
});
const hook = hooks.Notification![0].hooks[0];
await hook({ key: "value", count: 42 }, "id", {});
expect(emitEvent).toHaveBeenCalledWith(
"hook",
expect.objectContaining({
key: "value",
count: 42,
}),
);
});
});
});
});

View File

@ -426,4 +426,263 @@ describe("runSdkAgent", () => {
expect(firstPartial).toBeLessThanOrEqual(firstBlock);
});
});
describe("session resumption", () => {
it("extracts session_id from SDK init event", async () => {
mockQuery.mockImplementationOnce(async function* () {
yield { type: "system", subtype: "init", session_id: "sdk-session-abc123" };
yield { type: "assistant", text: "Hello" };
yield { type: "result", result: "Hello" };
});
const result = await runSdkAgent(baseParams);
expect(result.claudeSessionId).toBe("sdk-session-abc123");
expect(result.payloads[0]?.text).toBe("Hello");
});
it("extracts session_id from any event with session_id field", async () => {
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "Response", session_id: "session-from-assistant" };
yield { type: "result", result: "Response" };
});
const result = await runSdkAgent(baseParams);
expect(result.claudeSessionId).toBe("session-from-assistant");
});
it("passes resume option to SDK when claudeSessionId is provided", async () => {
mockQuery.mockImplementationOnce(async function* () {
yield { type: "result", result: "Resumed session response" };
});
await runSdkAgent({
...baseParams,
claudeSessionId: "existing-session-xyz",
});
expect(mockQuery).toHaveBeenCalledTimes(1);
const callArgs = mockQuery.mock.calls[0];
const options = callArgs?.[0]?.options;
expect(options?.resume).toBe("existing-session-xyz");
});
it("passes forkSession option when both claudeSessionId and forkSession are provided", async () => {
mockQuery.mockImplementationOnce(async function* () {
yield { type: "result", result: "Forked session response" };
});
await runSdkAgent({
...baseParams,
claudeSessionId: "session-to-fork",
forkSession: true,
});
expect(mockQuery).toHaveBeenCalledTimes(1);
const callArgs = mockQuery.mock.calls[0];
const options = callArgs?.[0]?.options;
expect(options?.resume).toBe("session-to-fork");
expect(options?.forkSession).toBe(true);
});
it("does not set resume option when claudeSessionId is not provided", async () => {
mockQuery.mockImplementationOnce(async function* () {
yield { type: "result", result: "New session response" };
});
await runSdkAgent(baseParams);
expect(mockQuery).toHaveBeenCalledTimes(1);
const callArgs = mockQuery.mock.calls[0];
const options = callArgs?.[0]?.options;
expect(options?.resume).toBeUndefined();
});
});
describe("callback error handling", () => {
it("continues streaming if onPartialReply throws synchronously", async () => {
const onPartialReply = vi.fn().mockImplementation(() => {
throw new Error("onPartialReply sync error");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "text", text: "First chunk" };
yield { type: "text", text: "Second chunk" };
yield { type: "result", result: "Final output" };
});
const result = await runSdkAgent({
...baseParams,
onPartialReply,
});
// Should complete despite callback error
expect(result.payloads[0]?.text).toBe("Final output");
expect(result.payloads[0]?.isError).toBeUndefined();
expect(onPartialReply).toHaveBeenCalled();
});
it("continues streaming if onPartialReply returns rejected promise", async () => {
const onPartialReply = vi.fn().mockRejectedValue(new Error("onPartialReply async error"));
mockQuery.mockImplementationOnce(async function* () {
yield { type: "text", text: "Streaming" };
yield { type: "result", result: "Done" };
});
const result = await runSdkAgent({
...baseParams,
onPartialReply,
});
expect(result.payloads[0]?.text).toBe("Done");
expect(result.payloads[0]?.isError).toBeUndefined();
});
it("continues streaming if onBlockReply throws", async () => {
const onBlockReply = vi.fn().mockImplementation(() => {
throw new Error("onBlockReply error");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "Block 1" };
yield { type: "assistant", text: "Block 2" };
yield { type: "result", result: "Block 2" };
});
const result = await runSdkAgent({
...baseParams,
onBlockReply,
});
expect(result.payloads[0]?.text).toBe("Block 2");
expect(onBlockReply).toHaveBeenCalled();
});
it("continues streaming if onReasoningStream throws", async () => {
const onReasoningStream = vi.fn().mockImplementation(() => {
throw new Error("onReasoningStream error");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "thinking", thinking: "Thinking content" };
yield { type: "assistant", text: "Response" };
yield { type: "result", result: "Response" };
});
const result = await runSdkAgent({
...baseParams,
thinkingLevel: "high",
onReasoningStream,
});
expect(result.payloads[0]?.text).toBe("Response");
expect(onReasoningStream).toHaveBeenCalled();
});
it("continues streaming if onBlockReplyFlush throws", async () => {
const onBlockReplyFlush = vi.fn().mockImplementation(() => {
throw new Error("onBlockReplyFlush error");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "Content" };
yield { type: "result", result: "Content" };
});
const result = await runSdkAgent({
...baseParams,
onBlockReply: vi.fn(),
onBlockReplyFlush,
});
expect(result.payloads[0]?.text).toBe("Content");
expect(onBlockReplyFlush).toHaveBeenCalled();
});
it("handles multiple callbacks throwing simultaneously", async () => {
const onPartialReply = vi.fn().mockRejectedValue(new Error("partial error"));
const onBlockReply = vi.fn().mockImplementation(() => {
throw new Error("block error");
});
const onReasoningStream = vi.fn().mockRejectedValue(new Error("reasoning error"));
const onAgentEvent = vi.fn().mockImplementation(() => {
throw new Error("event error");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "thinking", thinking: "Thinking..." };
yield { type: "assistant", text: "Response" };
yield { type: "result", result: "Response" };
});
const result = await runSdkAgent({
...baseParams,
thinkingLevel: "high",
onPartialReply,
onBlockReply,
onReasoningStream,
onAgentEvent,
});
// All callbacks should have been called, and result should still be correct
expect(result.payloads[0]?.text).toBe("Response");
expect(result.payloads[0]?.isError).toBeUndefined();
expect(onPartialReply).toHaveBeenCalled();
expect(onBlockReply).toHaveBeenCalled();
expect(onReasoningStream).toHaveBeenCalled();
expect(onAgentEvent).toHaveBeenCalled();
});
it("continues if onToolResult callback throws", async () => {
const onToolResult = vi.fn().mockImplementation(() => {
throw new Error("onToolResult error");
});
mockQuery.mockImplementationOnce(async function* () {
yield {
type: "tool_use",
tool_use_id: "tool-123",
name: "test_tool",
input: {},
};
yield {
type: "tool_result",
tool_use_id: "tool-123",
content: [{ type: "text", text: "Tool output" }],
};
yield { type: "assistant", text: "Here is the result" };
yield { type: "result", result: "Here is the result" };
});
const result = await runSdkAgent({
...baseParams,
onToolResult,
});
// Should complete despite callback error
expect(result.payloads[0]?.text).toBe("Here is the result");
});
it("completes run even if onAssistantMessageStart throws", async () => {
const onAssistantMessageStart = vi.fn().mockImplementation(() => {
throw new Error("onAssistantMessageStart error");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant_message_start" };
yield { type: "assistant", text: "Hello" };
yield { type: "result", result: "Hello" };
});
const result = await runSdkAgent({
...baseParams,
onAssistantMessageStart,
});
expect(result.payloads[0]?.text).toBe("Hello");
expect(onAssistantMessageStart).toHaveBeenCalled();
});
});
});

View File

@ -15,6 +15,7 @@
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { bridgeMoltbotToolsToMcpServer } from "./tool-bridge.js";
import type { SdkRunnerQueryOptions } from "./tool-bridge.types.js";
import { extractFromClaudeAgentSdkEvent } from "./extract.js";
@ -632,7 +633,28 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
});
}
// Standard agent event streams that should be emitted to the global system.
// These are consumed by the Chat UI and other listeners (gateway, diagnostics).
const GLOBAL_EVENT_STREAMS = new Set([
"lifecycle",
"tool",
"assistant",
"thinking",
"compaction",
]);
const emitEvent = (stream: string, data: Record<string, unknown>) => {
// Emit to global agent event system for standard streams.
// This allows the Chat UI to receive tool events, assistant text, etc.
if (GLOBAL_EVENT_STREAMS.has(stream) && params.runId) {
emitAgentEvent({
runId: params.runId,
stream,
data,
});
}
// Also emit via callback for typing signals, etc.
try {
void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => {
// Don't let async callback errors trigger unhandled rejections.
@ -643,7 +665,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
};
emitEvent("lifecycle", { phase: "start", startedAt, runtime: "sdk" });
emitEvent("sdk", { type: "sdk_runner_start", runId: params.runId });
// -------------------------------------------------------------------------
// Step 0: Run before_agent_start hooks
@ -708,8 +729,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
};
}
emitEvent("sdk", { type: "sdk_loaded" });
// -------------------------------------------------------------------------
// Step 2: Bridge Moltbot tools to MCP
// -------------------------------------------------------------------------
@ -767,11 +786,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
: ""),
);
emitEvent("sdk", {
type: "tools_bridged",
toolCount: bridgeResult.toolCount,
skipped: bridgeResult.skippedTools,
});
}
// -------------------------------------------------------------------------
@ -874,23 +888,15 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
if (value !== undefined) sdkEnv[key] = value;
}
// Add provider env vars (z.AI, custom endpoints, etc.).
// Add provider env vars (z.AI, custom endpoints, explicit API key, etc.).
// These override any inherited values from the parent process.
if (params.providerConfig?.env) {
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
providerName: params.providerConfig.name,
providerEnvKeys: Object.keys(params.providerConfig.env),
});
for (const [key, value] of Object.entries(params.providerConfig.env)) {
if (value !== undefined) {
const wasOverwritten = sdkEnv[key] !== undefined && sdkEnv[key] !== value;
if (wasOverwritten) {
log.debug("[CCSDK-ENV] Overwriting inherited env var", {
key,
oldValueMasked:
key.includes("KEY") || key.includes("TOKEN") ? maskToken(sdkEnv[key]) : sdkEnv[key],
newValueMasked: key.includes("KEY") || key.includes("TOKEN") ? maskToken(value) : value,
});
}
if (value !== undefined && value !== "") {
sdkEnv[key] = value;
}
}
@ -905,6 +911,21 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
if (value !== undefined) sdkEnv[key] = value;
}
// NOTE: We intentionally do NOT set CLAUDE_CONFIG_DIR.
// Setting it to a custom directory breaks auth because the SDK scopes
// credential resolution (including keychain lookups) to that directory.
// The user's `claude login` credentials are associated with ~/.claude,
// so we must let the SDK use the default config directory.
//
// TODO: If per-agent session isolation is needed, we may need to:
// 1. Symlink auth state from ~/.claude to the agent's config dir, OR
// 2. Use a different mechanism for session isolation that doesn't affect auth
if (params.ccsdkConfigDir) {
log.debug("[CCSDK-ENV] Ignoring ccsdkConfigDir to preserve auth - using default ~/.claude", {
ignoredConfigDir: params.ccsdkConfigDir,
});
}
// Log final auth env state
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
@ -984,8 +1005,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
: timeoutController.signal;
try {
emitEvent("sdk", { type: "query_start" });
log.debug("Starting SDK query", {
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
promptLength: typeof prompt === "string" ? prompt.length : undefined,
@ -1123,12 +1142,21 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
pendingToolCalls.delete(toolCallId);
}
// Include args for start events to match Pi runtime event shape.
const argsForEvent =
phase === "start" && record
? ((record.input as Record<string, unknown>) ??
(record.arguments as Record<string, unknown>) ??
undefined)
: undefined;
emitEvent("tool", {
phase,
name: normalizedName.name,
toolCallId,
sdkType: type,
...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}),
...(argsForEvent ? { args: argsForEvent } : {}),
isError,
...(toolText ? { resultText: toolText } : {}),
});
@ -1144,11 +1172,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
}
}
// Emit system/lifecycle events.
if (kind === "system" && isRecord(event)) {
emitEvent("sdk", event as Record<string, unknown>);
}
// Handle terminal result event.
if (kind === "result" && isRecord(event)) {
const result = event.result;
@ -1522,14 +1545,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
log.debug("[CCSDK-FINAL] No onBlockReplyFlush callback - UI may stay on typing indicator");
}
emitEvent("sdk", {
type: "sdk_runner_end",
eventCount,
extractedChars,
truncated,
aborted,
durationMs: Date.now() - startedAt,
});
emitEvent("lifecycle", {
phase: "end",
startedAt,

View File

@ -0,0 +1,481 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
// Mock dependencies
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
vi.mock("../tool-policy.js", () => ({
normalizeToolName: vi.fn((name: string) => name.toLowerCase().replace(/-/g, "_")),
}));
import {
extractJsonSchema,
convertToolResult,
wrapToolHandler,
mcpToolName,
buildMcpAllowedTools,
bridgeMoltbotToolsSync,
resetMcpServerCache,
} from "./tool-bridge.js";
import type { AnyAgentTool } from "../tools/common.js";
describe("tool-bridge", () => {
beforeEach(() => {
vi.clearAllMocks();
resetMcpServerCache();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("extractJsonSchema", () => {
it("extracts JSON Schema from TypeBox schema", () => {
const tool = {
name: "test_tool",
parameters: {
type: "object",
properties: {
input: { type: "string" },
},
required: ["input"],
},
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({
type: "object",
properties: {
input: { type: "string" },
},
required: ["input"],
});
});
it("returns empty object schema when no parameters defined", () => {
const tool = {
name: "no_params_tool",
parameters: undefined,
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({ type: "object", properties: {} });
});
it("returns empty object schema for null parameters", () => {
const tool = {
name: "null_params_tool",
parameters: null,
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({ type: "object", properties: {} });
});
it("strips non-serializable properties from schema", () => {
const symbolKey = Symbol("internal");
const tool = {
name: "symbol_tool",
parameters: {
type: "object",
[symbolKey]: "should be stripped",
properties: {},
},
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({ type: "object", properties: {} });
expect(Object.getOwnPropertySymbols(schema)).toHaveLength(0);
});
});
describe("convertToolResult", () => {
it("converts text content blocks", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "text", text: "Hello world" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([{ type: "text", text: "Hello world" }]);
expect(mcpResult.isError).toBeUndefined();
});
it("converts image content blocks", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([
{ type: "image", data: "base64data", mimeType: "image/png" },
]);
});
it("converts tool_error blocks with isError flag", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "tool_error", error: "Something went wrong" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([{ type: "text", text: "Something went wrong" }]);
expect(mcpResult.isError).toBe(true);
});
it("converts blocks with error field to isError", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "custom", error: "Custom error message" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.isError).toBe(true);
expect(mcpResult.content[0]).toEqual({ type: "text", text: "Custom error message" });
});
it("returns (no output) for empty content", () => {
const result: AgentToolResult<unknown> = {
content: [],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([{ type: "text", text: "(no output)" }]);
});
it("serializes details as tool-details text block", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "text", text: "Output" }],
details: { key: "value", count: 42 },
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content.length).toBe(2);
expect(mcpResult.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("<tool-details>"),
});
expect(mcpResult.content[1]).toMatchObject({
text: expect.stringContaining('"key": "value"'),
});
});
it("handles null details gracefully", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "text", text: "Output" }],
details: null,
};
const mcpResult = convertToolResult(result);
// Should not add a details block for null
expect(mcpResult.content.length).toBe(1);
});
it("handles multiple content blocks", () => {
const result: AgentToolResult<unknown> = {
content: [
{ type: "text", text: "Line 1" },
{ type: "text", text: "Line 2" },
{ type: "image", data: "abc", mimeType: "image/jpeg" },
],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content.length).toBe(3);
});
});
describe("wrapToolHandler", () => {
it("executes tool and converts result", async () => {
const mockExecute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Tool output" }],
});
const tool = {
name: "test-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({ input: "test" });
expect(mockExecute).toHaveBeenCalledWith(
expect.stringContaining("mcp-bridge-test_tool"),
{ input: "test" },
undefined,
undefined,
);
expect(result.content).toEqual([{ type: "text", text: "Tool output" }]);
});
it("passes abort signal to tool execute", async () => {
const mockExecute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Done" }],
});
const tool = {
name: "abort-aware-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const controller = new AbortController();
const handler = wrapToolHandler(tool, controller.signal);
await handler({});
expect(mockExecute).toHaveBeenCalledWith(
expect.any(String),
{},
controller.signal,
undefined,
);
});
it("handles tool execution errors gracefully", async () => {
const mockExecute = vi.fn().mockRejectedValue(new Error("Tool crashed"));
const tool = {
name: "crashing-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("Tool error"),
});
});
it("returns abort message for AbortError", async () => {
const abortError = new Error("Aborted");
abortError.name = "AbortError";
const mockExecute = vi.fn().mockRejectedValue(abortError);
const tool = {
name: "abortable-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("was aborted"),
});
});
it("calls onToolUpdate callback with update data", async () => {
const mockExecute = vi.fn().mockImplementation(async (_id, _args, _signal, onUpdate) => {
onUpdate?.({ progress: 50 });
return { content: [{ type: "text", text: "Done" }] };
});
const tool = {
name: "updating-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const onToolUpdate = vi.fn();
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
await handler({});
expect(onToolUpdate).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "updating_tool",
update: { progress: 50 },
}),
);
});
it("does not break execution when onToolUpdate callback throws", async () => {
const mockExecute = vi.fn().mockImplementation(async (_id, _args, _signal, onUpdate) => {
onUpdate?.({ progress: 100 });
return { content: [{ type: "text", text: "Success" }] };
});
const tool = {
name: "callback-error-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const onToolUpdate = vi.fn().mockRejectedValue(new Error("Callback failed"));
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
// Should not throw even if callback fails
const result = await handler({});
expect(result.content[0]).toMatchObject({
type: "text",
text: "Success",
});
});
it("generates unique tool call IDs for each invocation", async () => {
const toolCallIds: string[] = [];
const mockExecute = vi.fn().mockImplementation(async (id) => {
toolCallIds.push(id);
return { content: [{ type: "text", text: "Done" }] };
});
const tool = {
name: "unique-id-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
await handler({});
await handler({});
expect(toolCallIds[0]).not.toBe(toolCallIds[1]);
expect(toolCallIds[0]).toContain("mcp-bridge-unique_id_tool");
expect(toolCallIds[1]).toContain("mcp-bridge-unique_id_tool");
});
});
describe("mcpToolName", () => {
it("formats tool name with server prefix", () => {
expect(mcpToolName("moltbot", "bash")).toBe("mcp__moltbot__bash");
});
it("handles multi-part tool names", () => {
expect(mcpToolName("my-server", "my_tool_name")).toBe("mcp__my-server__my_tool_name");
});
});
describe("buildMcpAllowedTools", () => {
it("builds allowed tools list from tool array", () => {
const tools = [{ name: "tool_a" }, { name: "tool_b" }] as AnyAgentTool[];
const allowed = buildMcpAllowedTools("moltbot", tools);
expect(allowed).toEqual(["mcp__moltbot__tool_a", "mcp__moltbot__tool_b"]);
});
it("returns empty array for empty tools list", () => {
const allowed = buildMcpAllowedTools("moltbot", []);
expect(allowed).toEqual([]);
});
});
describe("bridgeMoltbotToolsSync", () => {
// Helper to create a class-like McpServer mock (required because implementation uses `new`)
function createMockMcpServerClass(toolFn: ReturnType<typeof vi.fn>) {
return class MockMcpServer {
tool = toolFn;
constructor(_opts: { name: string; version: string }) {
// Constructor receives options
}
};
}
it("registers tools with MCP server", () => {
const registeredTools: string[] = [];
const toolFn = vi.fn((name: string) => {
registeredTools.push(name);
});
const MockMcpServer = createMockMcpServerClass(toolFn);
const tools = [
{
name: "tool_one",
description: "First tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
{
name: "tool_two",
description: "Second tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
] as unknown as AnyAgentTool[];
const result = bridgeMoltbotToolsSync({
name: "test-server",
tools,
McpServer: MockMcpServer as any,
});
expect(result.toolCount).toBe(2);
expect(result.registeredTools).toEqual(["tool_one", "tool_two"]);
expect(result.skippedTools).toEqual([]);
});
it("skips tools with empty names", () => {
const MockMcpServer = createMockMcpServerClass(vi.fn());
const tools = [
{ name: "", execute: vi.fn() },
{ name: " ", execute: vi.fn() },
{ name: "valid_tool", execute: vi.fn() },
] as unknown as AnyAgentTool[];
const result = bridgeMoltbotToolsSync({
name: "test-server",
tools,
McpServer: MockMcpServer as any,
});
expect(result.toolCount).toBe(1);
expect(result.registeredTools).toEqual(["valid_tool"]);
expect(result.skippedTools).toContain("(unnamed)");
});
it("returns correct server config structure", () => {
const MockMcpServer = createMockMcpServerClass(vi.fn());
const result = bridgeMoltbotToolsSync({
name: "my-server",
tools: [],
McpServer: MockMcpServer as any,
});
expect(result.serverConfig.type).toBe("sdk");
expect(result.serverConfig.name).toBe("my-server");
expect(result.serverConfig.instance).toBeInstanceOf(MockMcpServer);
});
it("handles tool registration errors gracefully", () => {
const toolFn = vi.fn((name: string) => {
if (name === "bad_tool") throw new Error("Registration failed");
});
const MockMcpServer = createMockMcpServerClass(toolFn);
const tools = [
{ name: "good_tool", execute: vi.fn() },
{ name: "bad_tool", execute: vi.fn() },
] as unknown as AnyAgentTool[];
const result = bridgeMoltbotToolsSync({
name: "test-server",
tools,
McpServer: MockMcpServer as any,
});
expect(result.registeredTools).toEqual(["good_tool"]);
expect(result.skippedTools).toContain("bad_tool");
});
});
});

View File

@ -153,6 +153,13 @@ export function wrapToolHandler(
// model's response.
const toolCallId = `mcp-bridge-${normalizedName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Debug: log received arguments to diagnose parameter passing issues
log.debug(`Tool ${normalizedName} received args`, {
argsKeys: Object.keys(args),
argsType: typeof args,
argsPreview: JSON.stringify(args).slice(0, 500),
});
// Create an onUpdate callback that forwards to the bridge callback.
const onUpdate = onToolUpdate
? (update: unknown) => {

View File

@ -211,6 +211,14 @@ export type SdkRunnerParams = {
*/
mcpServerName?: string;
/**
* Custom Claude Code config directory for session storage.
* When provided, sets CLAUDE_CONFIG_DIR environment variable to ensure
* Moltbot agent sessions are stored separately from standard Claude Code
* CLI sessions. Typically: ~/.clawdbot/agents/{agentId}/ccsdk/
*/
ccsdkConfigDir?: string;
/**
* Prior conversation history to serialize into the SDK prompt.
* Since the SDK is stateless, prior turns are injected as context

View File

@ -239,4 +239,297 @@ describe("cli credentials", () => {
provider: "openai-codex",
});
});
describe("Linux Secret Service", () => {
it("reads credentials from Linux Secret Service using secret-tool", async () => {
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
if (cmd.includes("secret-tool lookup")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "linux-access",
refreshToken: "linux-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
const { readClaudeCliCredentials } = await import("./cli-credentials.js");
const creds = readClaudeCliCredentials({
allowKeychainPrompt: true,
platform: "linux",
execSync: execSyncMock,
});
expect(creds).toMatchObject({
type: "oauth",
provider: "anthropic",
access: "linux-access",
refresh: "linux-refresh",
});
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining("secret-tool lookup"),
expect.any(Object),
);
});
it("falls back to file when Linux Secret Service is unavailable", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-linux-"));
const credPath = path.join(tempDir, ".claude", ".credentials.json");
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(
credPath,
JSON.stringify({
claudeAiOauth: {
accessToken: "file-access",
refreshToken: "file-refresh",
expiresAt: Date.now() + 60_000,
},
}),
"utf8",
);
execSyncMock.mockImplementation(() => {
throw new Error("secret-tool not found");
});
const { readClaudeCliCredentials } = await import("./cli-credentials.js");
const creds = readClaudeCliCredentials({
allowKeychainPrompt: true,
platform: "linux",
homeDir: tempDir,
execSync: execSyncMock,
});
expect(creds).toMatchObject({
access: "file-access",
refresh: "file-refresh",
});
});
it("writes credentials to Linux Secret Service", async () => {
const commands: string[] = [];
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
commands.push(cmd);
if (cmd.includes("secret-tool lookup")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
const { writeClaudeCliLinuxSecretServiceCredentials } = await import("./cli-credentials.js");
const ok = writeClaudeCliLinuxSecretServiceCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{ execSync: execSyncMock },
);
expect(ok).toBe(true);
expect(commands.some((cmd) => cmd.includes("secret-tool store"))).toBe(true);
});
});
describe("Windows Credential Manager", () => {
it("reads credentials from Windows Credential Manager using PowerShell", async () => {
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
if (cmd.includes("powershell") && cmd.includes("CredRead")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "windows-access",
refreshToken: "windows-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
const { readClaudeCliCredentials } = await import("./cli-credentials.js");
const creds = readClaudeCliCredentials({
allowKeychainPrompt: true,
platform: "win32",
execSync: execSyncMock,
});
expect(creds).toMatchObject({
type: "oauth",
provider: "anthropic",
access: "windows-access",
refresh: "windows-refresh",
});
expect(execSyncMock).toHaveBeenCalledWith(
expect.stringContaining("powershell"),
expect.any(Object),
);
});
it("falls back to file when Windows Credential Manager is unavailable", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-win-"));
const credPath = path.join(tempDir, ".claude", ".credentials.json");
fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 });
fs.writeFileSync(
credPath,
JSON.stringify({
claudeAiOauth: {
accessToken: "file-access",
refreshToken: "file-refresh",
expiresAt: Date.now() + 60_000,
},
}),
"utf8",
);
execSyncMock.mockImplementation(() => {
throw new Error("powershell not found");
});
const { readClaudeCliCredentials } = await import("./cli-credentials.js");
const creds = readClaudeCliCredentials({
allowKeychainPrompt: true,
platform: "win32",
homeDir: tempDir,
execSync: execSyncMock,
});
expect(creds).toMatchObject({
access: "file-access",
refresh: "file-refresh",
});
});
it("writes credentials to Windows Credential Manager", async () => {
const commands: string[] = [];
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
commands.push(cmd);
if (cmd.includes("CredRead")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
if (cmd.includes("CredWrite")) {
return "True";
}
return "";
});
const { writeClaudeCliWindowsCredentialManagerCredentials } =
await import("./cli-credentials.js");
const ok = writeClaudeCliWindowsCredentialManagerCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{ execSync: execSyncMock },
);
expect(ok).toBe(true);
expect(commands.some((cmd) => cmd.includes("CredWrite"))).toBe(true);
});
});
describe("writeClaudeCliCredentials cross-platform", () => {
it("uses Linux Secret Service on linux platform", async () => {
const commands: string[] = [];
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
commands.push(cmd);
if (cmd.includes("secret-tool lookup")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
return "";
});
const { writeClaudeCliCredentials } = await import("./cli-credentials.js");
const ok = writeClaudeCliCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{
platform: "linux",
execSync: execSyncMock,
},
);
expect(ok).toBe(true);
expect(commands.some((cmd) => cmd.includes("secret-tool"))).toBe(true);
});
it("uses Windows Credential Manager on win32 platform", async () => {
const commands: string[] = [];
execSyncMock.mockImplementation((command: unknown) => {
const cmd = String(command);
commands.push(cmd);
if (cmd.includes("CredRead")) {
return JSON.stringify({
claudeAiOauth: {
accessToken: "old-access",
refreshToken: "old-refresh",
expiresAt: Date.now() + 60_000,
},
});
}
if (cmd.includes("CredWrite")) {
return "True";
}
return "";
});
const { writeClaudeCliCredentials } = await import("./cli-credentials.js");
const ok = writeClaudeCliCredentials(
{
access: "new-access",
refresh: "new-refresh",
expires: Date.now() + 60_000,
},
{
platform: "win32",
execSync: execSyncMock,
},
);
expect(ok).toBe(true);
expect(commands.some((cmd) => cmd.includes("powershell"))).toBe(true);
});
});
});

View File

@ -30,6 +30,9 @@ const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
// Windows Credential Manager target name (matches Claude Code CLI)
const CLAUDE_CLI_WINDOWS_TARGET = "Claude Code-credentials";
type CachedValue<T> = {
value: T | null;
readAt: number;
@ -84,6 +87,7 @@ type ClaudeCliFileOptions = {
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
platform?: NodeJS.Platform;
execSync?: ExecSyncFn;
writeKeychain?: (credentials: OAuthCredentials) => boolean;
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
};
@ -276,6 +280,236 @@ function readClaudeCliKeychainCredentials(
}
}
/**
* Read Claude CLI credentials from Linux Secret Service (libsecret).
*
* This uses the `secret-tool` CLI which interfaces with GNOME Keyring,
* KWallet, or any freedesktop.org Secret Service D-Bus compatible keyring.
*
* Requires: libsecret-tools package (apt install libsecret-tools on Debian/Ubuntu)
*/
function readClaudeCliLinuxSecretServiceCredentials(
execSyncImpl: ExecSyncFn = execSync,
): ClaudeCliCredential | null {
log.debug("[CCSDK-AUTH] Attempting to read credentials from Linux Secret Service", {
service: CLAUDE_CLI_KEYCHAIN_SERVICE,
});
try {
// secret-tool lookup returns the secret value for matching attributes
const result = execSyncImpl(`secret-tool lookup service "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, {
encoding: "utf8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
});
if (!result || !result.trim()) {
log.debug("[CCSDK-AUTH] Linux Secret Service returned empty result");
return null;
}
log.debug("[CCSDK-AUTH] Linux Secret Service returned data", {
rawLength: result.length,
});
const data = JSON.parse(result.trim());
const claudeOauth = data?.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") {
log.debug("[CCSDK-AUTH] Linux Secret Service data missing claudeAiOauth object");
return null;
}
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) {
log.debug("[CCSDK-AUTH] Linux Secret Service accessToken invalid or missing");
return null;
}
if (typeof expiresAt !== "number" || expiresAt <= 0) {
log.debug("[CCSDK-AUTH] Linux Secret Service expiresAt invalid", { expiresAt });
return null;
}
const expiresDate = new Date(expiresAt);
const isExpired = expiresAt < Date.now();
const msUntilExpiry = expiresAt - Date.now();
log.debug("[CCSDK-AUTH] Linux Secret Service credentials parsed successfully", {
source: "linux-secret-service",
hasAccessToken: Boolean(accessToken),
accessTokenMasked: maskToken(accessToken),
hasRefreshToken: Boolean(refreshToken),
refreshTokenMasked: maskToken(refreshToken),
expiresAt: expiresDate.toISOString(),
isExpired,
msUntilExpiry,
type: refreshToken ? "oauth" : "token",
});
if (typeof refreshToken === "string" && refreshToken) {
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
};
} catch (err) {
log.debug("[CCSDK-AUTH] Linux Secret Service read failed", {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
/**
* Read Claude CLI credentials from Windows Credential Manager.
*
* This uses PowerShell to retrieve credentials from Windows Credential Manager
* using the CredRead API. The credential is stored as a "Generic" credential.
*/
function readClaudeCliWindowsCredentialManagerCredentials(
execSyncImpl: ExecSyncFn = execSync,
): ClaudeCliCredential | null {
log.debug("[CCSDK-AUTH] Attempting to read credentials from Windows Credential Manager", {
target: CLAUDE_CLI_WINDOWS_TARGET,
});
try {
// PowerShell script to read from Windows Credential Manager
// Uses the .NET CredentialManager API via Add-Type
const psScript = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class CredManager {
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool CredFree(IntPtr credential);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public int Flags;
public int Type;
public string TargetName;
public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
public static string GetCredential(string target) {
IntPtr credPtr;
if (!CredRead(target, 1, 0, out credPtr)) return null;
try {
CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL));
if (cred.CredentialBlobSize > 0) {
return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / 2);
}
return null;
} finally {
CredFree(credPtr);
}
}
}
"@
$result = [CredManager]::GetCredential("${CLAUDE_CLI_WINDOWS_TARGET}")
if ($result) { Write-Output $result }
`.replace(/\n/g, " ");
const result = execSyncImpl(
`powershell -NoProfile -Command "${psScript.replace(/"/g, '\\"')}"`,
{
encoding: "utf8",
timeout: 10000,
stdio: ["pipe", "pipe", "pipe"],
},
);
if (!result || !result.trim()) {
log.debug("[CCSDK-AUTH] Windows Credential Manager returned empty result");
return null;
}
log.debug("[CCSDK-AUTH] Windows Credential Manager returned data", {
rawLength: result.length,
});
const data = JSON.parse(result.trim());
const claudeOauth = data?.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") {
log.debug("[CCSDK-AUTH] Windows Credential Manager data missing claudeAiOauth object");
return null;
}
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) {
log.debug("[CCSDK-AUTH] Windows Credential Manager accessToken invalid or missing");
return null;
}
if (typeof expiresAt !== "number" || expiresAt <= 0) {
log.debug("[CCSDK-AUTH] Windows Credential Manager expiresAt invalid", { expiresAt });
return null;
}
const expiresDate = new Date(expiresAt);
const isExpired = expiresAt < Date.now();
const msUntilExpiry = expiresAt - Date.now();
log.debug("[CCSDK-AUTH] Windows Credential Manager credentials parsed successfully", {
source: "windows-credential-manager",
hasAccessToken: Boolean(accessToken),
accessTokenMasked: maskToken(accessToken),
hasRefreshToken: Boolean(refreshToken),
refreshTokenMasked: maskToken(refreshToken),
expiresAt: expiresDate.toISOString(),
isExpired,
msUntilExpiry,
type: refreshToken ? "oauth" : "token",
});
if (typeof refreshToken === "string" && refreshToken) {
return {
type: "oauth",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
expires: expiresAt,
};
}
return {
type: "token",
provider: "anthropic",
token: accessToken,
expires: expiresAt,
};
} catch (err) {
log.debug("[CCSDK-AUTH] Windows Credential Manager read failed", {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
export function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
platform?: NodeJS.Platform;
@ -290,22 +524,40 @@ export function readClaudeCliCredentials(options?: {
homeDir: options?.homeDir ?? "(default)",
});
// Try macOS Keychain first
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
if (keychainCreds) {
log.info("[CCSDK-AUTH] Successfully read credentials from macOS Keychain", {
type: keychainCreds.type,
source: "keychain",
accessTokenMasked:
keychainCreds.type === "oauth"
? maskToken(keychainCreds.access)
: maskToken(keychainCreds.token),
});
return keychainCreds;
// Try platform-specific secure credential storage first
if (options?.allowKeychainPrompt !== false) {
let secureStoreCreds: ClaudeCliCredential | null = null;
let source = "";
if (platform === "darwin") {
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
secureStoreCreds = readClaudeCliKeychainCredentials(options?.execSync);
source = "macos-keychain";
} else if (platform === "linux") {
log.debug("[CCSDK-AUTH] Attempting Linux Secret Service read");
secureStoreCreds = readClaudeCliLinuxSecretServiceCredentials(options?.execSync);
source = "linux-secret-service";
} else if (platform === "win32") {
log.debug("[CCSDK-AUTH] Attempting Windows Credential Manager read");
secureStoreCreds = readClaudeCliWindowsCredentialManagerCredentials(options?.execSync);
source = "windows-credential-manager";
}
if (secureStoreCreds) {
log.info("[CCSDK-AUTH] Successfully read credentials from secure storage", {
type: secureStoreCreds.type,
source,
accessTokenMasked:
secureStoreCreds.type === "oauth"
? maskToken(secureStoreCreds.access)
: maskToken(secureStoreCreds.token),
});
return secureStoreCreds;
}
if (source) {
log.debug("[CCSDK-AUTH] Secure storage read returned null, falling back to file", { source });
}
log.debug("[CCSDK-AUTH] Keychain read returned null, falling back to file");
}
// Fall back to file-based credentials
@ -458,6 +710,210 @@ export function writeClaudeCliKeychainCredentials(
}
}
/**
* Write Claude CLI credentials to Linux Secret Service (libsecret).
*
* Updates existing credentials in GNOME Keyring, KWallet, or compatible keyring.
*/
export function writeClaudeCliLinuxSecretServiceCredentials(
newCredentials: OAuthCredentials,
options?: { execSync?: ExecSyncFn },
): boolean {
const execSyncImpl = options?.execSync ?? execSync;
try {
// First read existing data to preserve other fields
const existingResult = execSyncImpl(
`secret-tool lookup service "${CLAUDE_CLI_KEYCHAIN_SERVICE}" 2>/dev/null`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
if (!existingResult || !existingResult.trim()) {
log.debug("[CCSDK-AUTH] No existing Linux Secret Service entry to update");
return false;
}
const existingData = JSON.parse(existingResult.trim());
const existingOauth = existingData?.claudeAiOauth;
if (!existingOauth || typeof existingOauth !== "object") {
return false;
}
existingData.claudeAiOauth = {
...existingOauth,
accessToken: newCredentials.access,
refreshToken: newCredentials.refresh,
expiresAt: newCredentials.expires,
};
const newValue = JSON.stringify(existingData);
// Write back using secret-tool store (overwrites existing entry with same attributes)
execSyncImpl(
`echo -n '${newValue.replace(/'/g, "'\\''")}' | secret-tool store --label="Claude Code" service "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], shell: "/bin/bash" },
);
log.info("wrote refreshed credentials to linux secret service", {
expires: new Date(newCredentials.expires).toISOString(),
});
return true;
} catch (error) {
log.warn("failed to write credentials to linux secret service", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
/**
* Write Claude CLI credentials to Windows Credential Manager.
*
* Updates existing credentials in Windows Credential Manager.
*/
export function writeClaudeCliWindowsCredentialManagerCredentials(
newCredentials: OAuthCredentials,
options?: { execSync?: ExecSyncFn },
): boolean {
const execSyncImpl = options?.execSync ?? execSync;
try {
// First read existing data to preserve other fields
const psReadScript = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class CredManager {
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool CredFree(IntPtr credential);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public int Flags;
public int Type;
public string TargetName;
public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
public static string GetCredential(string target) {
IntPtr credPtr;
if (!CredRead(target, 1, 0, out credPtr)) return null;
try {
CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL));
if (cred.CredentialBlobSize > 0) {
return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / 2);
}
return null;
} finally {
CredFree(credPtr);
}
}
}
"@
$result = [CredManager]::GetCredential("${CLAUDE_CLI_WINDOWS_TARGET}")
if ($result) { Write-Output $result }
`.replace(/\n/g, " ");
const existingResult = execSyncImpl(
`powershell -NoProfile -Command "${psReadScript.replace(/"/g, '\\"')}"`,
{ encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
);
if (!existingResult || !existingResult.trim()) {
log.debug("[CCSDK-AUTH] No existing Windows Credential Manager entry to update");
return false;
}
const existingData = JSON.parse(existingResult.trim());
const existingOauth = existingData?.claudeAiOauth;
if (!existingOauth || typeof existingOauth !== "object") {
return false;
}
existingData.claudeAiOauth = {
...existingOauth,
accessToken: newCredentials.access,
refreshToken: newCredentials.refresh,
expiresAt: newCredentials.expires,
};
const newValue = JSON.stringify(existingData);
// Write back using PowerShell CredWrite
const psWriteScript = `
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class CredWriter {
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CredWrite(ref CREDENTIAL credential, int flags);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct CREDENTIAL {
public int Flags;
public int Type;
public string TargetName;
public string Comment;
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
public int CredentialBlobSize;
public IntPtr CredentialBlob;
public int Persist;
public int AttributeCount;
public IntPtr Attributes;
public string TargetAlias;
public string UserName;
}
public static bool WriteCredential(string target, string secret) {
byte[] byteArray = Encoding.Unicode.GetBytes(secret);
CREDENTIAL cred = new CREDENTIAL();
cred.Type = 1;
cred.TargetName = target;
cred.CredentialBlobSize = byteArray.Length;
cred.CredentialBlob = Marshal.AllocHGlobal(byteArray.Length);
Marshal.Copy(byteArray, 0, cred.CredentialBlob, byteArray.Length);
cred.Persist = 2;
cred.UserName = "Claude Code";
try {
return CredWrite(ref cred, 0);
} finally {
Marshal.FreeHGlobal(cred.CredentialBlob);
}
}
}
"@
[CredWriter]::WriteCredential("${CLAUDE_CLI_WINDOWS_TARGET}", '${newValue.replace(/'/g, "''")}')
`.replace(/\n/g, " ");
const writeResult = execSyncImpl(
`powershell -NoProfile -Command "${psWriteScript.replace(/"/g, '\\"')}"`,
{ encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] },
);
if (writeResult.trim() === "True") {
log.info("wrote refreshed credentials to windows credential manager", {
expires: new Date(newCredentials.expires).toISOString(),
});
return true;
}
log.warn("failed to write credentials to windows credential manager", {
result: writeResult.trim(),
});
return false;
} catch (error) {
log.warn("failed to write credentials to windows credential manager", {
error: error instanceof Error ? error.message : String(error),
});
return false;
}
}
export function writeClaudeCliFileCredentials(
newCredentials: OAuthCredentials,
options?: ClaudeCliFileOptions,
@ -506,13 +962,29 @@ export function writeClaudeCliCredentials(
options?.writeFile ??
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
// Try platform-specific secure storage first
if (platform === "darwin") {
const didWriteKeychain = writeKeychain(newCredentials);
if (didWriteKeychain) {
const didWrite = writeKeychain(newCredentials);
if (didWrite) {
return true;
}
} else if (platform === "linux") {
const didWrite = writeClaudeCliLinuxSecretServiceCredentials(newCredentials, {
execSync: options?.execSync,
});
if (didWrite) {
return true;
}
} else if (platform === "win32") {
const didWrite = writeClaudeCliWindowsCredentialManagerCredentials(newCredentials, {
execSync: options?.execSync,
});
if (didWrite) {
return true;
}
}
// Fall back to file-based storage
return writeFile(newCredentials, { homeDir: options?.homeDir });
}

View File

@ -28,7 +28,7 @@ import {
} from "./cli-runner/helpers.js";
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js";
import type { AgentRunResult } from "./runtime-result-types.js";
const log = createSubsystemLogger("agent/claude-cli");
@ -49,7 +49,7 @@ export async function runCliAgent(params: {
ownerNumbers?: string[];
cliSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
}): Promise<AgentRunResult> {
const started = Date.now();
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const workspaceDir = resolvedWorkspace;
@ -314,7 +314,7 @@ export async function runClaudeCliAgent(params: {
ownerNumbers?: string[];
claudeSessionId?: string;
images?: ImageContent[];
}): Promise<EmbeddedPiRunResult> {
}): Promise<AgentRunResult> {
return runCliAgent({
sessionId: params.sessionId,
sessionKey: params.sessionKey,

View File

@ -0,0 +1,226 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
// Mock the dependent modules
vi.mock("./pi-agent-runtime.js", () => ({
createPiAgentRuntime: vi.fn(() => ({
kind: "pi",
displayName: "Pi Agent",
run: vi.fn(),
})),
}));
vi.mock("./agent-scope.js", () => ({
resolveAgentConfig: vi.fn(),
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
// Dynamic import mock for CCSDK
const mockCcSdkRuntime = {
kind: "ccsdk" as const,
displayName: "Claude Code SDK",
run: vi.fn(),
};
const mockIsSdkAvailable = vi.fn(() => true);
const mockCreateCcSdkAgentRuntime = vi.fn(() => mockCcSdkRuntime);
vi.mock("./claude-agent-sdk/index.js", () => ({
createCcSdkAgentRuntime: mockCreateCcSdkAgentRuntime,
isSdkAvailable: mockIsSdkAvailable,
}));
import {
resolveAgentRuntimeKind,
createAgentRuntime,
isCcSdkRuntimeAvailable,
} from "./main-agent-runtime-factory.js";
import { createPiAgentRuntime } from "./pi-agent-runtime.js";
import { resolveAgentConfig } from "./agent-scope.js";
describe("main-agent-runtime-factory", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsSdkAvailable.mockReturnValue(true);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("resolveAgentRuntimeKind", () => {
it("returns per-agent runtime when configured as 'pi'", () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" });
const result = resolveAgentRuntimeKind({} as MoltbotConfig, "test-agent");
expect(result).toBe("pi");
});
it("returns per-agent runtime when configured as 'ccsdk'", () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "ccsdk" });
const result = resolveAgentRuntimeKind({} as MoltbotConfig, "test-agent");
expect(result).toBe("ccsdk");
});
it("falls back to defaults.runtime when per-agent not configured", () => {
vi.mocked(resolveAgentConfig).mockReturnValue(undefined);
const config: MoltbotConfig = {
agents: {
defaults: {
runtime: "ccsdk",
},
},
};
const result = resolveAgentRuntimeKind(config, "test-agent");
expect(result).toBe("ccsdk");
});
it("falls back to 'pi' when no runtime is configured", () => {
vi.mocked(resolveAgentConfig).mockReturnValue(undefined);
const config: MoltbotConfig = {};
const result = resolveAgentRuntimeKind(config, "test-agent");
expect(result).toBe("pi");
});
it("ignores invalid per-agent runtime values", () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "invalid" as any });
const config: MoltbotConfig = {
agents: {
defaults: {
runtime: "ccsdk",
},
},
};
const result = resolveAgentRuntimeKind(config, "test-agent");
expect(result).toBe("ccsdk");
});
it("ignores invalid defaults.runtime values", () => {
vi.mocked(resolveAgentConfig).mockReturnValue(undefined);
const config: MoltbotConfig = {
agents: {
defaults: {
runtime: "bogus" as any,
},
},
};
const result = resolveAgentRuntimeKind(config, "test-agent");
expect(result).toBe("pi");
});
it("prefers per-agent runtime over defaults", () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" });
const config: MoltbotConfig = {
agents: {
defaults: {
runtime: "ccsdk",
},
},
};
const result = resolveAgentRuntimeKind(config, "test-agent");
expect(result).toBe("pi");
});
});
describe("createAgentRuntime", () => {
it("creates Pi runtime when resolved kind is 'pi'", async () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" });
const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent");
expect(createPiAgentRuntime).toHaveBeenCalled();
expect(runtime.kind).toBe("pi");
});
it("creates CCSDK runtime when resolved kind is 'ccsdk' and SDK available", async () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "ccsdk" });
mockIsSdkAvailable.mockReturnValue(true);
const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent");
expect(mockCreateCcSdkAgentRuntime).toHaveBeenCalled();
expect(runtime.kind).toBe("ccsdk");
});
it("falls back to Pi runtime when CCSDK requested but SDK unavailable", async () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "ccsdk" });
mockIsSdkAvailable.mockReturnValue(false);
const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent");
expect(createPiAgentRuntime).toHaveBeenCalled();
expect(runtime.kind).toBe("pi");
});
it("respects forceKind parameter to override config resolution", async () => {
vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" });
mockIsSdkAvailable.mockReturnValue(true);
const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent", "ccsdk");
expect(mockCreateCcSdkAgentRuntime).toHaveBeenCalled();
expect(runtime.kind).toBe("ccsdk");
});
it("passes CCSDK config to createCcSdkAgentRuntime", async () => {
const ccsdkConfig = { modelTiers: { primary: "claude-sonnet-4-20250514" } };
vi.mocked(resolveAgentConfig).mockReturnValue({
runtime: "ccsdk",
ccsdk: ccsdkConfig,
});
mockIsSdkAvailable.mockReturnValue(true);
const config: MoltbotConfig = {};
await createAgentRuntime(config, "test-agent");
expect(mockCreateCcSdkAgentRuntime).toHaveBeenCalledWith({
config,
ccsdkConfig,
});
});
});
describe("isCcSdkRuntimeAvailable", () => {
it("returns true when SDK is available", async () => {
mockIsSdkAvailable.mockReturnValue(true);
const result = await isCcSdkRuntimeAvailable();
expect(result).toBe(true);
});
it("returns false when SDK is unavailable", async () => {
mockIsSdkAvailable.mockReturnValue(false);
const result = await isCcSdkRuntimeAvailable();
expect(result).toBe(false);
});
});
});

View File

@ -36,11 +36,13 @@ export function createPiAgentRuntime(): AgentRuntime {
// Spread shared params directly, then add Pi-specific options from the bag
return runEmbeddedPiAgent({
...params,
// Pi-specific options from piOptions bag override any shared fields
// Pi-specific options from piOptions bag
enforceFinalTag: piOpts.enforceFinalTag,
execOverrides: piOpts.execOverrides,
bashElevated: piOpts.bashElevated,
clientTools: piOpts.clientTools,
// Client tools (now at top level, applicable to both runtimes)
clientTools: params.clientTools,
// Shared params (execOverrides and bashElevated are now at top level)
execOverrides: params.execOverrides,
bashElevated: params.bashElevated,
});
},
};

View File

@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import { ensureMoltbotModelsJson } from "./models-config.js";
import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js";
import { buildAgentSandboxInfo } from "./pi-embedded-runner.js";
import type { SandboxContext } from "./sandbox.js";
vi.mock("@mariozechner/pi-ai", async () => {
@ -97,9 +97,9 @@ const _readSessionMessages = async (sessionFile: string) => {
.map((entry) => entry.message as { role?: string; content?: unknown });
};
describe("buildEmbeddedSandboxInfo", () => {
describe("buildAgentSandboxInfo", () => {
it("returns undefined when sandbox is missing", () => {
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
expect(buildAgentSandboxInfo()).toBeUndefined();
});
it("maps sandbox context into prompt info", () => {
const sandbox = {
@ -133,7 +133,7 @@ describe("buildEmbeddedSandboxInfo", () => {
},
} satisfies SandboxContext;
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
expect(buildAgentSandboxInfo(sandbox)).toEqual({
enabled: true,
workspaceDir: "/tmp/moltbot-sandbox",
workspaceAccess: "none",
@ -171,7 +171,7 @@ describe("buildEmbeddedSandboxInfo", () => {
} satisfies SandboxContext;
expect(
buildEmbeddedSandboxInfo(sandbox, {
buildAgentSandboxInfo(sandbox, {
enabled: true,
allowed: true,
defaultLevel: "on",

View File

@ -16,12 +16,19 @@ export {
queueEmbeddedPiMessage,
waitForEmbeddedPiRunEnd,
} from "./pi-embedded-runner/runs.js";
export { buildEmbeddedSandboxInfo } from "./pi-embedded-runner/sandbox-info.js";
export { buildAgentSandboxInfo } from "./pi-embedded-runner/sandbox-info.js";
export { createSystemPromptOverride } from "./pi-embedded-runner/system-prompt.js";
export { splitSdkTools } from "./pi-embedded-runner/tool-split.js";
// Re-export shared result types (generalized names)
export type {
EmbeddedPiAgentMeta,
EmbeddedPiCompactResult,
EmbeddedPiRunMeta,
EmbeddedPiRunResult,
} from "./pi-embedded-runner/types.js";
AgentRunMeta,
AgentRunResultMeta,
AgentRunResult,
AgentRuntimeKind,
} from "./runtime-result-types.js";
// Re-export Pi-specific types
export type { EmbeddedPiCompactResult } from "./pi-embedded-runner/types.js";
// Re-export shared sandbox info type
export type { AgentSandboxInfo } from "./runtime-result-types.js";

View File

@ -61,7 +61,7 @@ import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { buildModelAliasLines, resolveModel } from "./model.js";
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
import { buildAgentSandboxInfo } from "./sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js";
import { splitSdkTools } from "./tool-split.js";
@ -304,7 +304,7 @@ export async function compactEmbeddedPiSessionDirect(
capabilities: runtimeCapabilities,
channelActions,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const sandboxInfo = buildAgentSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);

View File

@ -50,7 +50,7 @@ import { resolveModel } from "./model.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
import { buildEmbeddedRunPayloads } from "./run/payloads.js";
import type { EmbeddedPiAgentMeta, EmbeddedPiRunResult } from "./types.js";
import type { AgentRunMeta, AgentRunResult } from "../runtime-result-types.js";
import { describeUnknownError } from "./utils.js";
type ApiKeyInfo = ResolvedProviderAuth;
@ -69,7 +69,7 @@ function scrubAnthropicRefusalMagic(prompt: string): string {
export async function runEmbeddedPiAgent(
params: RunEmbeddedPiAgentParams,
): Promise<EmbeddedPiRunResult> {
): Promise<AgentRunResult> {
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
@ -611,7 +611,7 @@ export async function runEmbeddedPiAgent(
}
const usage = normalizeUsage(lastAssistant?.usage as UsageLike);
const agentMeta: EmbeddedPiAgentMeta = {
const agentMeta: AgentRunMeta = {
sessionId: sessionIdUsed,
provider: lastAssistant?.provider ?? provider,
model: lastAssistant?.model ?? model.id,

View File

@ -70,7 +70,7 @@ import {
type EmbeddedPiQueueHandle,
setActiveEmbeddedRun,
} from "../runs.js";
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
import { buildAgentSandboxInfo } from "../sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
@ -285,7 +285,7 @@ export async function runEmbeddedAttempt(
sessionKey: params.sessionKey,
config: params.config,
});
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const sandboxInfo = buildAgentSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel

View File

@ -6,16 +6,10 @@ import type { enqueueCommand } from "../../../process/command-queue.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
import type { SkillSnapshot } from "../../skills.js";
import type { ClientToolDefinition } from "../../runtime-result-types.js";
// Simplified tool definition for client-provided tools (OpenResponses hosted tools)
export type ClientToolDefinition = {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
};
// Re-export for backwards compatibility
export type { ClientToolDefinition } from "../../runtime-result-types.js";
export type RunEmbeddedPiAgentParams = {
sessionId: string;

View File

@ -1,11 +1,11 @@
import type { ExecElevatedDefaults } from "../bash-tools.js";
import type { resolveSandboxContext } from "../sandbox.js";
import type { EmbeddedSandboxInfo } from "./types.js";
import type { AgentSandboxInfo } from "../runtime-result-types.js";
export function buildEmbeddedSandboxInfo(
export function buildAgentSandboxInfo(
sandbox?: Awaited<ReturnType<typeof resolveSandboxContext>>,
execElevated?: ExecElevatedDefaults,
): EmbeddedSandboxInfo | undefined {
): AgentSandboxInfo | undefined {
if (!sandbox?.enabled) return undefined;
const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed);
return {

View File

@ -3,7 +3,7 @@ import type { ResolvedTimeFormat } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js";
import { buildToolSummaryMap } from "../tool-summaries.js";
import type { EmbeddedSandboxInfo } from "./types.js";
import type { AgentSandboxInfo } from "../runtime-result-types.js";
import type { ReasoningLevel, ThinkLevel } from "./utils.js";
export function buildEmbeddedSystemPrompt(params: {
@ -38,7 +38,7 @@ export function buildEmbeddedSystemPrompt(params: {
channelActions?: string[];
};
messageToolHints?: string[];
sandboxInfo?: EmbeddedSandboxInfo;
sandboxInfo?: AgentSandboxInfo;
tools: AgentTool[];
modelAliasLines: string[];
userTimezone: string;

View File

@ -1,56 +1,12 @@
import type { MessagingToolSend } from "../pi-embedded-messaging.js";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
export type EmbeddedPiAgentMeta = {
sessionId: string;
provider: string;
model: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
};
export type EmbeddedPiRunMeta = {
durationMs: number;
agentMeta?: EmbeddedPiAgentMeta;
aborted?: boolean;
systemPromptReport?: SessionSystemPromptReport;
error?: {
kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size";
message: string;
};
/** Stop reason for the agent run (e.g., "completed", "tool_calls"). */
stopReason?: string;
/** Pending tool calls when stopReason is "tool_calls". */
pendingToolCalls?: Array<{
id: string;
name: string;
arguments: string;
}>;
};
export type EmbeddedPiRunResult = {
payloads?: Array<{
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
replyToId?: string;
isError?: boolean;
}>;
meta: EmbeddedPiRunMeta;
// True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send)
// successfully sent a message. Used to suppress agent's confirmation text.
didSendViaMessagingTool?: boolean;
// Texts successfully sent via messaging tools during the run.
messagingToolSentTexts?: string[];
// Messaging tool targets that successfully sent a message during the run.
messagingToolSentTargets?: MessagingToolSend[];
};
/**
* Pi runtime-specific types.
*
* For shared agent result types, see ../runtime-result-types.ts
*/
/**
* Result from a Pi session compaction operation.
*/
export type EmbeddedPiCompactResult = {
ok: boolean;
compacted: boolean;
@ -64,16 +20,12 @@ export type EmbeddedPiCompactResult = {
};
};
export type EmbeddedSandboxInfo = {
enabled: boolean;
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserBridgeUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
};
};
// Re-export shared types for convenience
export {
type AgentRunMeta,
type AgentRunResultMeta,
type AgentRunPayload,
type AgentRunResult,
type AgentRuntimeKind,
type AgentSandboxInfo,
} from "../runtime-result-types.js";

View File

@ -1,9 +1,8 @@
export type {
EmbeddedPiAgentMeta,
EmbeddedPiCompactResult,
EmbeddedPiRunMeta,
EmbeddedPiRunResult,
} from "./pi-embedded-runner.js";
// Re-export shared agent result types
export type { AgentRunMeta, AgentRunResultMeta, AgentRunResult } from "./runtime-result-types.js";
// Re-export Pi-specific types
export type { EmbeddedPiCompactResult } from "./pi-embedded-runner.js";
export {
abortEmbeddedPiRun,
compactEmbeddedPiSession,

View File

@ -0,0 +1,144 @@
/**
* Shared result types for all agent runtimes.
*
* These types define the common contract for agent run results across
* different execution backends (Pi Agent, Claude Agent SDK, etc.).
*/
import type { MessagingToolSend } from "./pi-embedded-messaging.js";
import type { SessionSystemPromptReport } from "../config/sessions/types.js";
/** Runtime backend discriminant for agent runs. */
export type AgentRuntimeKind = "pi" | "ccsdk";
/**
* Simplified tool definition for client-provided tools (OpenResponses hosted tools).
*
* These tools are defined by external clients (e.g., via OpenResponses API) and
* return "pending" results when called - execution is delegated back to the client.
*/
export type ClientToolDefinition = {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
};
/**
* Agent metadata returned from a run.
*
* Contains information about the session, provider, model, and token usage.
*/
export type AgentRunMeta = {
/** Session ID for this run (provider-specific, used for session resumption). */
sessionId: string;
/** Provider that executed this run (e.g., "anthropic", "openai"). */
provider: string;
/** Model used for this run. */
model: string;
/** Runtime backend that executed this run. */
runtime?: AgentRuntimeKind;
/** Token usage statistics. */
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
};
/**
* Metadata about an agent run execution.
*
* Contains timing, status, and error information.
*/
export type AgentRunResultMeta = {
/** Duration of the run in milliseconds. */
durationMs: number;
/** Agent-specific metadata (session, provider, model, usage). */
agentMeta?: AgentRunMeta;
/** Whether the run was aborted. */
aborted?: boolean;
/** System prompt report for diagnostics. */
systemPromptReport?: SessionSystemPromptReport;
/** Error information if the run failed. */
error?: {
kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size";
message: string;
};
/** Stop reason for the agent run (e.g., "completed", "tool_calls"). */
stopReason?: string;
/** Pending tool calls when stopReason is "tool_calls". */
pendingToolCalls?: Array<{
id: string;
name: string;
arguments: string;
}>;
};
/**
* Result payload from an agent run.
*/
export type AgentRunPayload = {
/** Text content of the payload. */
text?: string;
/** Single media URL (legacy, prefer mediaUrls). */
mediaUrl?: string;
/** Media URLs attached to the payload. */
mediaUrls?: string[];
/** Message ID to reply to. */
replyToId?: string;
/** Whether this payload represents an error. */
isError?: boolean;
};
/**
* Complete result from an agent run.
*
* This is the shared result type returned by all agent runtimes.
*/
export type AgentRunResult = {
/** Response payloads from the agent. */
payloads?: AgentRunPayload[];
/** Execution metadata. */
meta: AgentRunResultMeta;
/**
* True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send)
* successfully sent a message. Used to suppress agent's confirmation text.
*/
didSendViaMessagingTool?: boolean;
/** Texts successfully sent via messaging tools during the run. */
messagingToolSentTexts?: string[];
/** Messaging tool targets that successfully sent a message during the run. */
messagingToolSentTargets?: MessagingToolSend[];
};
/**
* Sandbox/workspace configuration for agent runs.
*
* Describes the execution environment constraints and capabilities.
*/
export type AgentSandboxInfo = {
/** Whether sandboxing is enabled. */
enabled: boolean;
/** Workspace directory path. */
workspaceDir?: string;
/** Workspace access level. */
workspaceAccess?: "none" | "ro" | "rw";
/** Mount point for agent workspace (when read-only). */
agentWorkspaceMount?: string;
/** Browser bridge URL for browser automation. */
browserBridgeUrl?: string;
/** NoVNC URL for browser viewing. */
browserNoVncUrl?: string;
/** Whether host browser control is allowed. */
hostBrowserAllowed?: boolean;
/** Elevated execution permissions. */
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
};
};

View File

@ -47,67 +47,123 @@ const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const;
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (kind) determines which properties are relevant; runtime validates.
const BrowserActSchema = Type.Object({
kind: stringEnum(BROWSER_ACT_KINDS),
kind: stringEnum(BROWSER_ACT_KINDS, {
description:
"Action type: click, type, press, hover, drag, select, fill, resize, wait, evaluate, close.",
}),
// Common fields
targetId: Type.Optional(Type.String()),
ref: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String({ description: "Tab ID to target (from tabs action)." })),
ref: Type.Optional(
Type.String({ description: "Element reference from snapshot (e.g., 'E1', 'button[0]')." }),
),
// click
doubleClick: Type.Optional(Type.Boolean()),
button: Type.Optional(Type.String()),
modifiers: Type.Optional(Type.Array(Type.String())),
doubleClick: Type.Optional(
Type.Boolean({ description: "Perform a double-click instead of single-click." }),
),
button: Type.Optional(
Type.String({ description: "Mouse button: 'left', 'right', or 'middle'." }),
),
modifiers: Type.Optional(
Type.Array(Type.String(), {
description: "Keyboard modifiers to hold: 'Alt', 'Control', 'Meta', 'Shift'.",
}),
),
// type
text: Type.Optional(Type.String()),
submit: Type.Optional(Type.Boolean()),
slowly: Type.Optional(Type.Boolean()),
text: Type.Optional(Type.String({ description: "Text to type into the focused element." })),
submit: Type.Optional(Type.Boolean({ description: "Press Enter after typing to submit." })),
slowly: Type.Optional(
Type.Boolean({ description: "Type slowly with delays between keystrokes." }),
),
// press
key: Type.Optional(Type.String()),
key: Type.Optional(
Type.String({ description: "Key to press (e.g., 'Enter', 'Escape', 'Tab')." }),
),
// drag
startRef: Type.Optional(Type.String()),
endRef: Type.Optional(Type.String()),
startRef: Type.Optional(Type.String({ description: "Starting element reference for drag." })),
endRef: Type.Optional(Type.String({ description: "Ending element reference for drag." })),
// select
values: Type.Optional(Type.Array(Type.String())),
values: Type.Optional(
Type.Array(Type.String(), { description: "Values to select in a dropdown/select element." }),
),
// fill - use permissive array of objects
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
fields: Type.Optional(
Type.Array(Type.Object({}, { additionalProperties: true }), {
description: "Array of field objects to fill in a form.",
}),
),
// resize
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number({ description: "New viewport width in pixels." })),
height: Type.Optional(Type.Number({ description: "New viewport height in pixels." })),
// wait
timeMs: Type.Optional(Type.Number()),
textGone: Type.Optional(Type.String()),
timeMs: Type.Optional(Type.Number({ description: "Time to wait in milliseconds." })),
textGone: Type.Optional(
Type.String({ description: "Wait until this text disappears from the page." }),
),
// evaluate
fn: Type.Optional(Type.String()),
fn: Type.Optional(
Type.String({ description: "JavaScript function body to evaluate in the page context." }),
),
});
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
export const BrowserToolSchema = Type.Object({
action: stringEnum(BROWSER_TOOL_ACTIONS),
target: optionalStringEnum(BROWSER_TARGETS),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
maxChars: Type.Optional(Type.Number()),
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS),
interactive: Type.Optional(Type.Boolean()),
compact: Type.Optional(Type.Boolean()),
depth: Type.Optional(Type.Number()),
selector: Type.Optional(Type.String()),
frame: Type.Optional(Type.String()),
labels: Type.Optional(Type.Boolean()),
fullPage: Type.Optional(Type.Boolean()),
ref: Type.Optional(Type.String()),
element: Type.Optional(Type.String()),
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
level: Type.Optional(Type.String()),
paths: Type.Optional(Type.Array(Type.String())),
inputRef: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
accept: Type.Optional(Type.Boolean()),
promptText: Type.Optional(Type.String()),
action: stringEnum(BROWSER_TOOL_ACTIONS, {
description:
"Browser action: status, start, stop, profiles, tabs, open, focus, close, snapshot, screenshot, navigate, console, pdf, upload, dialog, act.",
}),
target: optionalStringEnum(BROWSER_TARGETS, {
description: "Browser target: 'sandbox' (isolated), 'host' (local), or 'node' (paired node).",
}),
node: Type.Optional(Type.String({ description: "Node ID or name when target='node'." })),
profile: Type.Optional(
Type.String({ description: "Browser profile name (for persistent state/cookies)." }),
),
targetUrl: Type.Optional(
Type.String({ description: "URL to navigate to when opening a new tab." }),
),
targetId: Type.Optional(Type.String({ description: "Tab ID to target (from tabs action)." })),
limit: Type.Optional(
Type.Number({ description: "Limit for console logs or other list results." }),
),
maxChars: Type.Optional(
Type.Number({ description: "Maximum characters to return in snapshot text." }),
),
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES, {
description: "Snapshot mode: 'efficient' for optimized output.",
}),
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS, {
description: "Snapshot format: 'aria' (accessibility tree) or 'ai' (AI-optimized).",
}),
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS, {
description: "Reference style for elements: 'role' or 'aria'.",
}),
interactive: Type.Optional(
Type.Boolean({ description: "Include only interactive elements in snapshot." }),
),
compact: Type.Optional(Type.Boolean({ description: "Use compact snapshot format." })),
depth: Type.Optional(Type.Number({ description: "Maximum DOM depth to traverse." })),
selector: Type.Optional(Type.String({ description: "CSS selector to scope the snapshot to." })),
frame: Type.Optional(Type.String({ description: "Frame name or index to target." })),
labels: Type.Optional(Type.Boolean({ description: "Include element labels in snapshot." })),
fullPage: Type.Optional(
Type.Boolean({ description: "Capture full page screenshot (not just viewport)." }),
),
ref: Type.Optional(Type.String({ description: "Element reference for act or screenshot." })),
element: Type.Optional(Type.String({ description: "Element selector for screenshot." })),
type: optionalStringEnum(BROWSER_IMAGE_TYPES, {
description: "Screenshot image format: 'png' or 'jpeg'.",
}),
level: Type.Optional(
Type.String({ description: "Console log level filter (e.g., 'error', 'warn')." }),
),
paths: Type.Optional(Type.Array(Type.String(), { description: "File paths for upload action." })),
inputRef: Type.Optional(Type.String({ description: "File input element reference for upload." })),
timeoutMs: Type.Optional(Type.Number({ description: "Timeout in milliseconds for the action." })),
accept: Type.Optional(
Type.Boolean({ description: "Accept or dismiss the dialog (for dialog action)." }),
),
promptText: Type.Optional(Type.String({ description: "Text to enter in a prompt dialog." })),
request: Type.Optional(BrowserActSchema),
});

View File

@ -24,29 +24,43 @@ const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
// Flattened schema: runtime validates per-action requirements.
const CanvasToolSchema = Type.Object({
action: stringEnum(CANVAS_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
action: stringEnum(CANVAS_ACTIONS, {
description: "Canvas action: present, hide, navigate, eval, snapshot, a2ui_push, a2ui_reset.",
}),
gatewayUrl: Type.Optional(
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
),
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
node: Type.Optional(Type.String({ description: "Node ID or name to target." })),
// present
target: Type.Optional(Type.String()),
x: Type.Optional(Type.Number()),
y: Type.Optional(Type.Number()),
width: Type.Optional(Type.Number()),
height: Type.Optional(Type.Number()),
target: Type.Optional(Type.String({ description: "URL to load when presenting the canvas." })),
x: Type.Optional(Type.Number({ description: "Canvas window X position in pixels." })),
y: Type.Optional(Type.Number({ description: "Canvas window Y position in pixels." })),
width: Type.Optional(Type.Number({ description: "Canvas window width in pixels." })),
height: Type.Optional(Type.Number({ description: "Canvas window height in pixels." })),
// navigate
url: Type.Optional(Type.String()),
url: Type.Optional(Type.String({ description: "URL to navigate the canvas to." })),
// eval
javaScript: Type.Optional(Type.String()),
javaScript: Type.Optional(
Type.String({ description: "JavaScript code to evaluate in the canvas context." }),
),
// snapshot
outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS, {
description: "Snapshot image format: 'png', 'jpg', or 'jpeg'.",
}),
maxWidth: Type.Optional(Type.Number({ description: "Maximum snapshot width in pixels." })),
quality: Type.Optional(
Type.Number({ description: "Image quality (0-100) for JPEG compression." }),
),
delayMs: Type.Optional(
Type.Number({ description: "Delay in milliseconds before capturing snapshot." }),
),
// a2ui_push
jsonl: Type.Optional(Type.String()),
jsonlPath: Type.Optional(Type.String()),
jsonl: Type.Optional(Type.String({ description: "A2UI JSONL content to push directly." })),
jsonlPath: Type.Optional(
Type.String({ description: "Path to A2UI JSONL file to read and push." }),
),
});
export function createCanvasTool(): AnyAgentTool {

View File

@ -24,19 +24,49 @@ const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
// Flattened schema: runtime validates per-action requirements.
const CronToolSchema = Type.Object({
action: stringEnum(CRON_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
includeDisabled: Type.Optional(Type.Boolean()),
job: Type.Optional(Type.Object({}, { additionalProperties: true })),
jobId: Type.Optional(Type.String()),
id: Type.Optional(Type.String()),
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
text: Type.Optional(Type.String()),
mode: optionalStringEnum(CRON_WAKE_MODES),
action: stringEnum(CRON_ACTIONS, {
description: "Cron action: status, list, add, update, remove, run, runs, wake.",
}),
gatewayUrl: Type.Optional(
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
),
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
includeDisabled: Type.Optional(
Type.Boolean({ description: "Include disabled jobs in list output." }),
),
job: Type.Optional(
Type.Object(
{},
{
additionalProperties: true,
description:
"Job definition object for add action. See tool description for schema details.",
},
),
),
jobId: Type.Optional(Type.String({ description: "Job ID for update/remove/run/runs actions." })),
id: Type.Optional(Type.String({ description: "Deprecated: use jobId instead." })),
patch: Type.Optional(
Type.Object(
{},
{
additionalProperties: true,
description: "Partial job update object for update action.",
},
),
),
text: Type.Optional(Type.String({ description: "Wake event text message (for wake action)." })),
mode: optionalStringEnum(CRON_WAKE_MODES, {
description: "Wake mode: 'now' (immediate) or 'next-heartbeat' (default).",
}),
contextMessages: Type.Optional(
Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }),
Type.Number({
description:
"Number of recent conversation messages (0-10) to include as context in the job text.",
minimum: 0,
maximum: REMINDER_CONTEXT_MESSAGES_MAX,
}),
),
});

View File

@ -37,21 +37,43 @@ const GATEWAY_ACTIONS = [
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
// The discriminator (action) determines which properties are relevant; runtime validates.
const GatewayToolSchema = Type.Object({
action: stringEnum(GATEWAY_ACTIONS),
action: stringEnum(GATEWAY_ACTIONS, {
description:
"Gateway action: restart, config.get, config.schema, config.apply, config.patch, update.run.",
}),
// restart
delayMs: Type.Optional(Type.Number()),
reason: Type.Optional(Type.String()),
delayMs: Type.Optional(
Type.Number({ description: "Delay in milliseconds before restarting (for restart action)." }),
),
reason: Type.Optional(
Type.String({ description: "Human-readable reason for the restart (logged)." }),
),
// config.get, config.schema, config.apply, update.run
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
),
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
// config.apply, config.patch
raw: Type.Optional(Type.String()),
baseHash: Type.Optional(Type.String()),
raw: Type.Optional(
Type.String({
description:
"Raw YAML/JSON config content. Required for config.apply (full replace) and config.patch (partial merge).",
}),
),
baseHash: Type.Optional(
Type.String({
description: "Base config hash for optimistic concurrency. Auto-fetched if omitted.",
}),
),
// config.apply, config.patch, update.run
sessionKey: Type.Optional(Type.String()),
note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()),
sessionKey: Type.Optional(
Type.String({ description: "Session key to notify after restart completes." }),
),
note: Type.Optional(Type.String({ description: "Note to include in the restart notification." })),
restartDelayMs: Type.Optional(
Type.Number({ description: "Delay in milliseconds before restarting after config change." }),
),
});
// NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here:
// - OpenAI rejects tool schemas that include these keywords at the *top-level*.

View File

@ -324,10 +324,27 @@ export function createImageTool(options?: {
name: "image",
description,
parameters: Type.Object({
prompt: Type.Optional(Type.String()),
image: Type.String(),
model: Type.Optional(Type.String()),
maxBytesMb: Type.Optional(Type.Number()),
prompt: Type.Optional(
Type.String({
description:
"The prompt or question to ask about the image. Defaults to 'Describe the image.'",
}),
),
image: Type.String({
description:
"Path to the image file, a file:// URL, a data: URL, or an http(s) URL to analyze.",
}),
model: Type.Optional(
Type.String({
description:
"Vision model override (e.g., 'openai/gpt-5-mini'). Uses configured imageModel if omitted.",
}),
),
maxBytesMb: Type.Optional(
Type.Number({
description: "Maximum image size in megabytes. Larger images are rejected.",
}),
),
}),
execute: async (_toolCallId, args) => {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};

View File

@ -8,15 +8,38 @@ import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const MemorySearchSchema = Type.Object({
query: Type.String(),
maxResults: Type.Optional(Type.Number()),
minScore: Type.Optional(Type.Number()),
query: Type.String({
description:
"The semantic search query to find relevant memories. Be specific and descriptive for best results.",
}),
maxResults: Type.Optional(
Type.Number({
description: "Maximum number of results to return. Defaults to 10.",
}),
),
minScore: Type.Optional(
Type.Number({
description:
"Minimum similarity score (0-1) for results. Higher values return more relevant but fewer results.",
}),
),
});
const MemoryGetSchema = Type.Object({
path: Type.String(),
from: Type.Optional(Type.Number()),
lines: Type.Optional(Type.Number()),
path: Type.String({
description:
"Relative path to the memory file (e.g., 'MEMORY.md' or 'memory/projects.md'). Use paths from memory_search results.",
}),
from: Type.Optional(
Type.Number({
description: "Starting line number (1-indexed). Omit to start from the beginning.",
}),
),
lines: Type.Optional(
Type.Number({
description: "Number of lines to read. Omit to read to the end of file.",
}),
),
});
export function createMemorySearchTool(options: {

View File

@ -25,17 +25,19 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
function buildRoutingSchema() {
return {
channel: Type.Optional(Type.String()),
channel: Type.Optional(
Type.String({ description: "Channel provider (e.g., 'telegram', 'discord', 'slack')." }),
),
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })),
targets: Type.Optional(channelTargetsSchema()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String({ description: "Account ID for multi-account setups." })),
dryRun: Type.Optional(Type.Boolean({ description: "Simulate the action without sending." })),
};
}
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
message: Type.Optional(Type.String({ description: "Text message content to send." })),
effectId: Type.Optional(
Type.String({
description: "Message effect name/id for sendWithEffect (e.g., invisible ink).",
@ -44,37 +46,49 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
effect: Type.Optional(
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
),
media: Type.Optional(Type.String()),
filename: Type.Optional(Type.String()),
media: Type.Optional(Type.String({ description: "URL or path to media file to attach." })),
filename: Type.Optional(Type.String({ description: "Filename for the attachment." })),
buffer: Type.Optional(
Type.String({
description: "Base64 payload for attachments (optionally a data: URL).",
}),
),
contentType: Type.Optional(Type.String()),
mimeType: Type.Optional(Type.String()),
caption: Type.Optional(Type.String()),
path: Type.Optional(Type.String()),
filePath: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
silent: Type.Optional(Type.Boolean()),
quoteText: Type.Optional(
Type.String({ description: "Quote text for Telegram reply_parameters" }),
contentType: Type.Optional(
Type.String({ description: "MIME content type of the attachment." }),
),
mimeType: Type.Optional(
Type.String({ description: "MIME type of the attachment (alias for contentType)." }),
),
caption: Type.Optional(Type.String({ description: "Caption for media attachments." })),
path: Type.Optional(Type.String({ description: "Local file path to attach." })),
filePath: Type.Optional(
Type.String({ description: "Local file path to attach (alias for path)." }),
),
replyTo: Type.Optional(Type.String({ description: "Message ID to reply to." })),
threadId: Type.Optional(Type.String({ description: "Thread/topic ID to send to." })),
asVoice: Type.Optional(Type.Boolean({ description: "Send audio as a voice message bubble." })),
silent: Type.Optional(Type.Boolean({ description: "Send without notification sound." })),
quoteText: Type.Optional(
Type.String({ description: "Quote text for Telegram reply_parameters." }),
),
bestEffort: Type.Optional(
Type.Boolean({ description: "Continue on partial failures (e.g., some targets failed)." }),
),
gifPlayback: Type.Optional(
Type.Boolean({ description: "Enable GIF autoplay for the attachment." }),
),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(
Type.Array(
Type.Array(
Type.Object({
text: Type.String(),
callback_data: Type.String(),
text: Type.String({ description: "Button label text." }),
callback_data: Type.String({
description: "Callback data sent when button is pressed.",
}),
}),
),
{
description: "Telegram inline keyboard buttons (array of button rows)",
description: "Telegram inline keyboard buttons (array of button rows).",
},
),
),
@ -83,7 +97,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
{},
{
additionalProperties: true,
description: "Adaptive Card JSON object (when supported by the channel)",
description: "Adaptive Card JSON object (when supported by the channel).",
},
),
),
@ -95,109 +109,141 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
function buildReactionSchema() {
return {
messageId: Type.Optional(Type.String()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
targetAuthor: Type.Optional(Type.String()),
targetAuthorUuid: Type.Optional(Type.String()),
groupId: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String({ description: "Message ID to react to." })),
emoji: Type.Optional(Type.String({ description: "Emoji character or name for the reaction." })),
remove: Type.Optional(Type.Boolean({ description: "Remove the reaction instead of adding." })),
targetAuthor: Type.Optional(
Type.String({ description: "Author of the message to react to (Signal)." }),
),
targetAuthorUuid: Type.Optional(
Type.String({ description: "UUID of the message author (Signal)." }),
),
groupId: Type.Optional(Type.String({ description: "Group ID for group reactions (Signal)." })),
};
}
function buildFetchSchema() {
return {
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
includeArchived: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number({ description: "Maximum number of messages to fetch." })),
before: Type.Optional(Type.String({ description: "Fetch messages before this message ID." })),
after: Type.Optional(Type.String({ description: "Fetch messages after this message ID." })),
around: Type.Optional(Type.String({ description: "Fetch messages around this message ID." })),
fromMe: Type.Optional(Type.Boolean({ description: "Filter to only messages from the bot." })),
includeArchived: Type.Optional(
Type.Boolean({ description: "Include archived messages in results." }),
),
};
}
function buildPollSchema() {
return {
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
pollQuestion: Type.Optional(Type.String({ description: "Poll question text." })),
pollOption: Type.Optional(
Type.Array(Type.String(), { description: "Array of poll answer options." }),
),
pollDurationHours: Type.Optional(
Type.Number({ description: "Poll duration in hours before closing." }),
),
pollMulti: Type.Optional(Type.Boolean({ description: "Allow multiple answer selections." })),
};
}
function buildChannelTargetSchema() {
return {
channelId: Type.Optional(
Type.String({ description: "Channel id filter (search/thread list/event create)." }),
Type.String({ description: "Channel ID filter (search/thread list/event create)." }),
),
channelIds: Type.Optional(
Type.Array(Type.String({ description: "Channel id filter (repeatable)." })),
Type.Array(Type.String(), { description: "Multiple channel IDs to filter by." }),
),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
participant: Type.Optional(Type.String()),
guildId: Type.Optional(Type.String({ description: "Discord server/guild ID." })),
userId: Type.Optional(Type.String({ description: "User ID to filter by." })),
authorId: Type.Optional(Type.String({ description: "Message author ID to filter by." })),
authorIds: Type.Optional(
Type.Array(Type.String(), { description: "Multiple author IDs to filter by." }),
),
roleId: Type.Optional(Type.String({ description: "Discord role ID to filter by." })),
roleIds: Type.Optional(
Type.Array(Type.String(), { description: "Multiple role IDs to filter by." }),
),
participant: Type.Optional(Type.String({ description: "Participant ID/name to filter by." })),
};
}
function buildStickerSchema() {
return {
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
emojiName: Type.Optional(Type.String({ description: "Emoji name to convert to sticker." })),
stickerId: Type.Optional(Type.Array(Type.String(), { description: "Sticker IDs to send." })),
stickerName: Type.Optional(Type.String({ description: "Name for creating a new sticker." })),
stickerDesc: Type.Optional(Type.String({ description: "Description for the sticker." })),
stickerTags: Type.Optional(Type.String({ description: "Tags/keywords for the sticker." })),
};
}
function buildThreadSchema() {
return {
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
threadName: Type.Optional(Type.String({ description: "Name for creating a new thread." })),
autoArchiveMin: Type.Optional(
Type.Number({ description: "Minutes of inactivity before auto-archiving the thread." }),
),
};
}
function buildEventSchema() {
return {
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
query: Type.Optional(Type.String({ description: "Search query for events." })),
eventName: Type.Optional(Type.String({ description: "Event name/title." })),
eventType: Type.Optional(Type.String({ description: "Event type (e.g., 'meeting', 'call')." })),
startTime: Type.Optional(
Type.String({ description: "Event start time (ISO 8601 or natural language)." }),
),
endTime: Type.Optional(
Type.String({ description: "Event end time (ISO 8601 or natural language)." }),
),
desc: Type.Optional(Type.String({ description: "Event description." })),
location: Type.Optional(
Type.String({ description: "Event location (physical address or URL)." }),
),
durationMin: Type.Optional(Type.Number({ description: "Event duration in minutes." })),
until: Type.Optional(Type.String({ description: "End date for recurring events." })),
};
}
function buildModerationSchema() {
return {
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
reason: Type.Optional(
Type.String({ description: "Reason for moderation action (logged/displayed)." }),
),
deleteDays: Type.Optional(
Type.Number({ description: "Number of days of messages to delete (ban action)." }),
),
};
}
function buildGatewaySchema() {
return {
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
gatewayUrl: Type.Optional(
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
),
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
};
}
function buildChannelManagementSchema() {
return {
name: Type.Optional(Type.String()),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
categoryId: Type.Optional(Type.String()),
name: Type.Optional(Type.String({ description: "Channel name." })),
type: Type.Optional(
Type.Number({ description: "Channel type (Discord channel type number)." }),
),
parentId: Type.Optional(Type.String({ description: "Parent category ID for the channel." })),
topic: Type.Optional(Type.String({ description: "Channel topic/description." })),
position: Type.Optional(Type.Number({ description: "Channel position in the list." })),
nsfw: Type.Optional(Type.Boolean({ description: "Mark channel as NSFW (age-restricted)." })),
rateLimitPerUser: Type.Optional(
Type.Number({ description: "Slowmode delay in seconds between messages." }),
),
categoryId: Type.Optional(Type.String({ description: "Category ID to move the channel to." })),
clearParent: Type.Optional(
Type.Boolean({
description: "Clear the parent/category when supported by the provider.",

View File

@ -48,44 +48,81 @@ const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const;
// Flattened schema: runtime validates per-action requirements.
const NodesToolSchema = Type.Object({
action: stringEnum(NODES_TOOL_ACTIONS),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
node: Type.Optional(Type.String()),
requestId: Type.Optional(Type.String()),
action: stringEnum(NODES_TOOL_ACTIONS, {
description:
"Node action: status, describe, pending, approve, reject, notify, camera_snap, camera_list, camera_clip, screen_record, location_get, run.",
}),
gatewayUrl: Type.Optional(
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
),
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
node: Type.Optional(Type.String({ description: "Node ID or name to target." })),
requestId: Type.Optional(
Type.String({ description: "Pairing request ID (for approve/reject actions)." }),
),
// notify
title: Type.Optional(Type.String()),
body: Type.Optional(Type.String()),
sound: Type.Optional(Type.String()),
priority: optionalStringEnum(NOTIFY_PRIORITIES),
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
title: Type.Optional(Type.String({ description: "Notification title." })),
body: Type.Optional(Type.String({ description: "Notification body text." })),
sound: Type.Optional(Type.String({ description: "Notification sound name." })),
priority: optionalStringEnum(NOTIFY_PRIORITIES, {
description: "Notification priority: 'passive', 'active', or 'timeSensitive'.",
}),
delivery: optionalStringEnum(NOTIFY_DELIVERIES, {
description: "Notification delivery mode: 'system', 'overlay', or 'auto'.",
}),
// camera_snap / camera_clip
facing: optionalStringEnum(CAMERA_FACING, {
description: "camera_snap: front/back/both; camera_clip: front/back only.",
}),
maxWidth: Type.Optional(Type.Number()),
quality: Type.Optional(Type.Number()),
delayMs: Type.Optional(Type.Number()),
deviceId: Type.Optional(Type.String()),
duration: Type.Optional(Type.String()),
durationMs: Type.Optional(Type.Number()),
includeAudio: Type.Optional(Type.Boolean()),
maxWidth: Type.Optional(
Type.Number({ description: "Maximum image width in pixels (camera_snap)." }),
),
quality: Type.Optional(
Type.Number({ description: "Image quality (0-100) for JPEG compression." }),
),
delayMs: Type.Optional(Type.Number({ description: "Delay in milliseconds before capturing." })),
deviceId: Type.Optional(Type.String({ description: "Specific camera device ID to use." })),
duration: Type.Optional(
Type.String({ description: "Recording duration as a string (e.g., '5s', '1m')." }),
),
durationMs: Type.Optional(Type.Number({ description: "Recording duration in milliseconds." })),
includeAudio: Type.Optional(
Type.Boolean({ description: "Include audio in camera_clip or screen_record." }),
),
// screen_record
fps: Type.Optional(Type.Number()),
screenIndex: Type.Optional(Type.Number()),
outPath: Type.Optional(Type.String()),
fps: Type.Optional(Type.Number({ description: "Frames per second for screen recording." })),
screenIndex: Type.Optional(
Type.Number({ description: "Screen/display index to record (0-based)." }),
),
outPath: Type.Optional(Type.String({ description: "Output file path for screen recording." })),
// location_get
maxAgeMs: Type.Optional(Type.Number()),
locationTimeoutMs: Type.Optional(Type.Number()),
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
maxAgeMs: Type.Optional(
Type.Number({ description: "Maximum age of cached location in milliseconds." }),
),
locationTimeoutMs: Type.Optional(
Type.Number({ description: "Location request timeout in milliseconds." }),
),
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY, {
description: "Location accuracy: 'coarse', 'balanced', or 'precise'.",
}),
// run
command: Type.Optional(Type.Array(Type.String())),
cwd: Type.Optional(Type.String()),
env: Type.Optional(Type.Array(Type.String())),
commandTimeoutMs: Type.Optional(Type.Number()),
invokeTimeoutMs: Type.Optional(Type.Number()),
needsScreenRecording: Type.Optional(Type.Boolean()),
command: Type.Optional(
Type.Array(Type.String(), { description: "Command argv array (e.g., ['echo', 'Hello'])." }),
),
cwd: Type.Optional(Type.String({ description: "Working directory for the command." })),
env: Type.Optional(
Type.Array(Type.String(), { description: "Environment variables as KEY=VALUE strings." }),
),
commandTimeoutMs: Type.Optional(
Type.Number({ description: "Timeout for the command execution in milliseconds." }),
),
invokeTimeoutMs: Type.Optional(
Type.Number({ description: "Timeout for the node invoke RPC in milliseconds." }),
),
needsScreenRecording: Type.Optional(
Type.Boolean({ description: "Hint that the command needs screen recording permission." }),
),
});
export function createNodesTool(options?: {

View File

@ -49,8 +49,17 @@ import {
import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js";
const SessionStatusToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
sessionKey: Type.Optional(
Type.String({
description: "Session key or session ID to get status for. Defaults to the current session.",
}),
),
model: Type.Optional(
Type.String({
description:
"Set a per-session model override (e.g., 'anthropic/claude-sonnet-4-20250514'). Use 'default' to reset overrides.",
}),
),
});
function formatApiKeySnippet(apiKey: string): string {

View File

@ -14,9 +14,20 @@ import {
} from "./sessions-helpers.js";
const SessionsHistoryToolSchema = Type.Object({
sessionKey: Type.String(),
limit: Type.Optional(Type.Number({ minimum: 1 })),
includeTools: Type.Optional(Type.Boolean()),
sessionKey: Type.String({
description: "The session key or session ID to fetch history from.",
}),
limit: Type.Optional(
Type.Number({
description: "Maximum number of messages to return.",
minimum: 1,
}),
),
includeTools: Type.Optional(
Type.Boolean({
description: "Include tool call/result messages in the output. Default: false (strips them).",
}),
),
});
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {

View File

@ -19,10 +19,30 @@ import {
} from "./sessions-helpers.js";
const SessionsListToolSchema = Type.Object({
kinds: Type.Optional(Type.Array(Type.String())),
limit: Type.Optional(Type.Number({ minimum: 1 })),
activeMinutes: Type.Optional(Type.Number({ minimum: 1 })),
messageLimit: Type.Optional(Type.Number({ minimum: 0 })),
kinds: Type.Optional(
Type.Array(Type.String(), {
description:
"Filter by session kinds: 'main', 'subagent', 'spawned', 'internal'. Omit for all kinds.",
}),
),
limit: Type.Optional(
Type.Number({
description: "Maximum number of sessions to return.",
minimum: 1,
}),
),
activeMinutes: Type.Optional(
Type.Number({
description: "Only include sessions active within the last N minutes.",
minimum: 1,
}),
),
messageLimit: Type.Optional(
Type.Number({
description: "Number of recent messages to include per session. 0 = no messages.",
minimum: 0,
}),
),
});
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {

View File

@ -29,11 +29,36 @@ import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./session
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
label: Type.Optional(Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH })),
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
sessionKey: Type.Optional(
Type.String({
description:
"Target session key (e.g., 'agent:main:abc123'). Use either sessionKey OR label, not both.",
}),
),
label: Type.Optional(
Type.String({
description:
"Human-readable session label to target. Use either sessionKey OR label, not both.",
minLength: 1,
maxLength: SESSION_LABEL_MAX_LENGTH,
}),
),
agentId: Type.Optional(
Type.String({
description: "Agent ID to scope the label lookup to (used with label parameter).",
minLength: 1,
maxLength: 64,
}),
),
message: Type.String({
description: "The message to send to the target session.",
}),
timeoutSeconds: Type.Optional(
Type.Number({
description: "Timeout in seconds for waiting for response. 0 = fire-and-forget.",
minimum: 0,
}),
),
});
export function createSessionsSendTool(opts?: {

View File

@ -26,15 +26,49 @@ import {
} from "./sessions-helpers.js";
const SessionsSpawnToolSchema = Type.Object({
task: Type.String(),
label: Type.Optional(Type.String()),
agentId: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
task: Type.String({
description: "The task or prompt to send to the spawned sub-agent session.",
}),
label: Type.Optional(
Type.String({
description: "Human-readable label for the spawned session (for tracking/display).",
}),
),
agentId: Type.Optional(
Type.String({
description:
"Target agent ID to spawn. Must be in the allowlist (agents.X.subagents.allowAgents). Defaults to the requester's agent.",
}),
),
model: Type.Optional(
Type.String({
description:
"Model override for the sub-agent (e.g., 'anthropic/claude-sonnet-4-20250514'). Uses configured subagent model if omitted.",
}),
),
thinking: Type.Optional(
Type.String({
description:
"Thinking level override (e.g., 'none', 'low', 'medium', 'high'). Model-dependent.",
}),
),
runTimeoutSeconds: Type.Optional(
Type.Number({
description: "Maximum time in seconds for the sub-agent run. 0 = no timeout.",
minimum: 0,
}),
),
// Back-compat alias. Prefer runTimeoutSeconds.
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
cleanup: optionalStringEnum(["delete", "keep"] as const),
timeoutSeconds: Type.Optional(
Type.Number({
description: "Deprecated: use runTimeoutSeconds instead.",
minimum: 0,
}),
),
cleanup: optionalStringEnum(["delete", "keep"] as const, {
description:
"Session cleanup policy after completion. 'delete' removes the session, 'keep' preserves it. Default: 'keep'.",
}),
});
function splitModelRef(ref?: string) {

View File

@ -0,0 +1,398 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import type { AgentRuntime, AgentRuntimeResult } from "./agent-runtime.js";
// Mock dependencies
const mockPiRuntime: AgentRuntime = {
kind: "pi",
displayName: "Pi Agent",
run: vi.fn(),
};
const mockCcsdkRuntime: AgentRuntime = {
kind: "ccsdk",
displayName: "Claude Code SDK",
run: vi.fn(),
};
vi.mock("./main-agent-runtime-factory.js", () => ({
createAgentRuntime: vi.fn(),
resolveAgentRuntimeKind: vi.fn(() => "pi"),
}));
vi.mock("./agent-scope.js", () => ({
resolveAgentModelFallbacksOverride: vi.fn(),
}));
vi.mock("./model-selection.js", () => ({
buildModelAliasIndex: vi.fn(() => new Map()),
modelKey: vi.fn((provider: string, model: string) => `${provider}/${model}`),
parseModelRef: vi.fn((raw: string, defaultProvider: string) => {
const [provider, model] = raw.includes("/") ? raw.split("/") : [defaultProvider, raw];
return { provider, model };
}),
resolveConfiguredModelRef: vi.fn(() => ({
provider: "anthropic",
model: "claude-sonnet-4-20250514",
})),
resolveModelRefFromString: vi.fn(
({ raw, defaultProvider }: { raw: string; defaultProvider: string }) => {
const [provider, model] = raw.includes("/") ? raw.split("/") : [defaultProvider, raw];
return { ref: { provider, model } };
},
),
}));
vi.mock("./auth-profiles.js", () => ({
ensureAuthProfileStore: vi.fn(() => ({
profiles: {},
cooldowns: {},
})),
isProfileInCooldown: vi.fn(() => false),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.mock("./failover-error.js", () => ({
coerceToFailoverError: vi.fn((err) => err),
describeFailoverError: vi.fn((err) => ({
message: err instanceof Error ? err.message : String(err),
reason: "rate_limit",
status: 429,
})),
isFailoverError: vi.fn((err) => err?.name === "FailoverError"),
isTimeoutError: vi.fn(() => false),
}));
vi.mock("../config/sessions.js", () => ({
resolveAgentIdFromSessionKey: vi.fn(() => "test-agent"),
}));
vi.mock("../utils/provider-utils.js", () => ({
isReasoningTagProvider: vi.fn(() => false),
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
vi.mock("./defaults.js", () => ({
DEFAULT_MODEL: "claude-sonnet-4-20250514",
DEFAULT_PROVIDER: "anthropic",
}));
import { runAgentWithUnifiedFailover } from "./unified-agent-runner.js";
import type { UnifiedAgentRunParams } from "./unified-agent-runner.js";
import { createAgentRuntime, resolveAgentRuntimeKind } from "./main-agent-runtime-factory.js";
import { isProfileInCooldown, resolveAuthProfileOrder } from "./auth-profiles.js";
import { coerceToFailoverError, isFailoverError } from "./failover-error.js";
describe("unified-agent-runner", () => {
const baseParams: UnifiedAgentRunParams = {
sessionId: "test-session-123",
sessionKey: "agent:test-agent:session",
sessionFile: "/tmp/test-session.jsonl",
workspaceDir: "/tmp/workspace",
prompt: "Hello, world!",
timeoutMs: 5000,
runId: "run-123",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
config: {} as MoltbotConfig,
};
const successResult: AgentRuntimeResult = {
payloads: [{ text: "Hello back!" }],
meta: {},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime);
vi.mocked(mockPiRuntime.run).mockResolvedValue(successResult);
vi.mocked(mockCcsdkRuntime.run).mockResolvedValue(successResult);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("runtime selection", () => {
it("attempts primary runtime first", async () => {
vi.mocked(resolveAgentRuntimeKind).mockReturnValue("pi");
vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime);
const result = await runAgentWithUnifiedFailover(baseParams);
expect(result.runtime).toBe("pi");
expect(mockPiRuntime.run).toHaveBeenCalled();
});
it("uses ccsdk as primary when configured", async () => {
vi.mocked(resolveAgentRuntimeKind).mockReturnValue("ccsdk");
vi.mocked(createAgentRuntime).mockImplementation(async (_config, _agentId, forceKind) => {
if (forceKind === "ccsdk") return mockCcsdkRuntime;
return mockPiRuntime;
});
const result = await runAgentWithUnifiedFailover(baseParams);
expect(result.runtime).toBe("ccsdk");
expect(mockCcsdkRuntime.run).toHaveBeenCalled();
});
});
describe("failover behavior", () => {
it("fails over to secondary runtime when primary fails", async () => {
const failoverError = Object.assign(new Error("Rate limit exceeded"), {
name: "FailoverError",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
vi.mocked(resolveAgentRuntimeKind).mockReturnValue("ccsdk");
vi.mocked(createAgentRuntime).mockImplementation(async (_config, _agentId, forceKind) => {
if (forceKind === "ccsdk") {
return {
...mockCcsdkRuntime,
run: vi.fn().mockRejectedValue(failoverError),
};
}
return mockPiRuntime;
});
const result = await runAgentWithUnifiedFailover(baseParams);
// Should have failed over to Pi after CCSDK failed
expect(result.runtime).toBe("pi");
expect(result.attempts.length).toBeGreaterThan(0);
expect(result.attempts[0].runtime).toBe("ccsdk");
});
it("tries all model candidates before switching runtime", async () => {
const failoverError = Object.assign(new Error("Model unavailable"), {
name: "FailoverError",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
// First runtime always fails
const failingRuntime: AgentRuntime = {
kind: "ccsdk",
displayName: "Claude Code SDK",
run: vi.fn().mockRejectedValue(failoverError),
};
vi.mocked(resolveAgentRuntimeKind).mockReturnValue("ccsdk");
vi.mocked(createAgentRuntime).mockImplementation(async (_config, _agentId, forceKind) => {
if (forceKind === "ccsdk") return failingRuntime;
return mockPiRuntime;
});
const paramsWithFallbacks: UnifiedAgentRunParams = {
...baseParams,
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
};
const result = await runAgentWithUnifiedFailover(paramsWithFallbacks);
// Should eventually succeed with Pi runtime
expect(result.runtime).toBe("pi");
// Should have attempted multiple models on CCSDK first
const ccsdkAttempts = result.attempts.filter((a) => a.runtime === "ccsdk");
expect(ccsdkAttempts.length).toBeGreaterThanOrEqual(1);
});
it("throws when all runtimes and models fail", async () => {
const failoverError = Object.assign(new Error("All failed"), { name: "FailoverError" });
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
vi.mocked(createAgentRuntime).mockResolvedValue({
...mockPiRuntime,
run: vi.fn().mockRejectedValue(failoverError),
});
await expect(runAgentWithUnifiedFailover(baseParams)).rejects.toThrow();
});
});
describe("callbacks", () => {
it("calls onModelSelected before each attempt", async () => {
const onModelSelected = vi.fn();
await runAgentWithUnifiedFailover({
...baseParams,
onModelSelected,
});
expect(onModelSelected).toHaveBeenCalledWith(
expect.objectContaining({
runtime: "pi",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
attempt: 1,
}),
);
});
it("calls onError on failure with error details", async () => {
const failoverError = Object.assign(new Error("Rate limit"), { name: "FailoverError" });
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const onError = vi.fn();
let attemptCount = 0;
vi.mocked(createAgentRuntime).mockResolvedValue({
...mockPiRuntime,
run: vi.fn().mockImplementation(async () => {
attemptCount++;
if (attemptCount === 1) throw failoverError;
return successResult;
}),
});
// Need a fallback to try again
await runAgentWithUnifiedFailover({
...baseParams,
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
onError,
});
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
runtime: "pi",
provider: "anthropic",
error: failoverError,
attempt: 1,
}),
);
});
it("does not break when onModelSelected throws", async () => {
const onModelSelected = vi.fn().mockRejectedValue(new Error("Callback error"));
// Should not throw even if callback fails
const result = await runAgentWithUnifiedFailover({
...baseParams,
onModelSelected,
});
expect(result.result).toBeDefined();
});
});
describe("auth profile handling", () => {
it("skips providers when all profiles are in cooldown", async () => {
// anthropic has profiles but all are in cooldown; openai has no profiles (no cooldown check)
vi.mocked(resolveAuthProfileOrder).mockImplementation(({ provider }) =>
provider === "anthropic" ? ["profile-1", "profile-2"] : [],
);
vi.mocked(isProfileInCooldown).mockReturnValue(true); // All anthropic profiles in cooldown
vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime);
// First candidate (anthropic) will be skipped due to cooldown, fallback (openai) will succeed
const result = await runAgentWithUnifiedFailover({
...baseParams,
fallbacksOverride: ["openai/gpt-4o"],
});
// Should record attempt as skipped due to cooldown
const cooldownAttempt = result.attempts.find((a) => a.reason === "rate_limit");
expect(cooldownAttempt).toBeDefined();
expect(cooldownAttempt?.provider).toBe("anthropic");
// Final result should be from the fallback provider
expect(result.provider).toBe("openai");
});
it("proceeds when at least one profile is available", async () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2"]);
vi.mocked(isProfileInCooldown).mockImplementation(
(_, profileId) => profileId === "profile-1",
);
const result = await runAgentWithUnifiedFailover(baseParams);
expect(result.result).toBeDefined();
});
});
describe("abort handling", () => {
it("rethrows AbortError immediately", async () => {
const abortError = new Error("Aborted");
abortError.name = "AbortError";
vi.mocked(mockPiRuntime.run).mockRejectedValue(abortError);
await expect(runAgentWithUnifiedFailover(baseParams)).rejects.toThrow("Aborted");
});
it("does not rethrow timeout errors as abort", async () => {
const timeoutError = Object.assign(new Error("Timeout"), { name: "FailoverError" });
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(timeoutError as any);
let attemptCount = 0;
vi.mocked(createAgentRuntime).mockResolvedValue({
...mockPiRuntime,
run: vi.fn().mockImplementation(async () => {
attemptCount++;
if (attemptCount === 1) throw timeoutError;
return successResult;
}),
});
// Should not throw, should failover
const result = await runAgentWithUnifiedFailover({
...baseParams,
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
});
expect(result.result).toBeDefined();
});
});
describe("result structure", () => {
it("returns complete result with runtime, provider, model, and attempts", async () => {
const result = await runAgentWithUnifiedFailover(baseParams);
expect(result.result).toBeDefined();
expect(result.runtime).toBe("pi");
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet-4-20250514");
expect(Array.isArray(result.attempts)).toBe(true);
});
it("records all failed attempts in attempts array", async () => {
const failoverError = Object.assign(new Error("Failed"), { name: "FailoverError" });
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
let attemptCount = 0;
vi.mocked(createAgentRuntime).mockResolvedValue({
...mockPiRuntime,
run: vi.fn().mockImplementation(async () => {
attemptCount++;
if (attemptCount < 2) throw failoverError;
return successResult;
}),
});
const result = await runAgentWithUnifiedFailover({
...baseParams,
fallbacksOverride: ["anthropic/claude-haiku-3-5"],
});
// First attempt should be recorded as failed
expect(result.attempts.length).toBeGreaterThan(0);
expect(result.attempts[0].error).toBeDefined();
});
});
});

View File

@ -303,14 +303,20 @@ export async function runAgentWithUnifiedFailover(
total: totalAttempts,
});
// Notify model selection before attempt
await params.onModelSelected?.({
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
attempt: attemptIndex,
total: totalAttempts,
});
// Notify model selection before attempt (wrapped in try/catch to be resilient)
try {
await params.onModelSelected?.({
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
attempt: attemptIndex,
total: totalAttempts,
});
} catch (err) {
log.warn("onModelSelected callback failed", {
error: err instanceof Error ? err.message : String(err),
});
}
// Determine if this is a different provider than originally requested
const originalProvider = params.provider ?? DEFAULT_PROVIDER;

View File

@ -0,0 +1,511 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AgentRuntimeKind, AgentRuntimeResult } from "./agent-runtime.js";
import type { RuntimeSlot, UnifiedRuntimeConfig } from "./unified-runtime-adapter.js";
// Mock dependencies
vi.mock("./failover-error.js", () => ({
coerceToFailoverError: vi.fn((err) => {
if (err?.name === "FailoverError") return err;
return null;
}),
describeFailoverError: vi.fn((err) => ({
message: err instanceof Error ? err.message : String(err),
reason: err?.reason ?? "rate_limit",
status: err?.status ?? 429,
code: err?.code,
})),
isFailoverError: vi.fn((err) => err?.name === "FailoverError"),
isTimeoutError: vi.fn(() => false),
}));
vi.mock("./auth-profiles.js", () => ({
ensureAuthProfileStore: vi.fn(() => ({
profiles: {},
cooldowns: new Map(),
})),
isProfileInCooldown: vi.fn(() => false),
markAuthProfileCooldown: vi.fn(),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
import { runWithUnifiedFallback, isRuntimeSlotAvailable } from "./unified-runtime-adapter.js";
import {
isProfileInCooldown,
markAuthProfileCooldown,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
import { coerceToFailoverError, isFailoverError } from "./failover-error.js";
describe("unified-runtime-adapter", () => {
const successResult: AgentRuntimeResult = {
payloads: [{ text: "Success" }],
meta: { durationMs: 100 },
};
const createConfig = (overrides?: Partial<UnifiedRuntimeConfig>): UnifiedRuntimeConfig => ({
primaryRuntime: "pi",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isProfileInCooldown).mockReturnValue(false);
vi.mocked(resolveAuthProfileOrder).mockReturnValue([]);
});
afterEach(() => {
vi.resetAllMocks();
});
describe("runWithUnifiedFallback", () => {
describe("basic execution", () => {
it("executes run function with primary runtime", async () => {
const runFn = vi.fn().mockResolvedValue(successResult);
const result = await runWithUnifiedFallback({
config: createConfig(),
run: runFn,
});
expect(runFn).toHaveBeenCalledWith(
expect.objectContaining({
runtime: "pi",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
}),
);
expect(result.runtime).toBe("pi");
expect(result.result).toBe(successResult);
});
it("returns result with correct structure", async () => {
const runFn = vi.fn().mockResolvedValue(successResult);
const result = await runWithUnifiedFallback({
config: createConfig(),
run: runFn,
});
expect(result).toMatchObject({
result: successResult,
runtime: "pi",
provider: "anthropic",
model: "claude-sonnet-4-20250514",
attempts: [],
});
});
});
describe("runtime failover", () => {
it("fails over to fallback runtime when primary fails", async () => {
const failoverError = Object.assign(new Error("Rate limit"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const runFn = vi
.fn()
.mockImplementation(async ({ runtime }: { runtime: AgentRuntimeKind }) => {
if (runtime === "ccsdk") throw failoverError;
return successResult;
});
const result = await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "ccsdk",
fallbackRuntimes: [{ runtime: "pi" }],
}),
run: runFn,
});
expect(result.runtime).toBe("pi");
expect(result.attempts.length).toBe(1);
expect(result.attempts[0].runtime).toBe("ccsdk");
});
it("tries all fallback runtimes in order", async () => {
const failoverError = Object.assign(new Error("Failed"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const runtimeOrder: AgentRuntimeKind[] = [];
let successOnThird = false;
const runFn = vi
.fn()
.mockImplementation(async ({ runtime }: { runtime: AgentRuntimeKind }) => {
runtimeOrder.push(runtime);
if (runtimeOrder.length < 3) throw failoverError;
successOnThird = true;
return successResult;
});
await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "ccsdk",
fallbackRuntimes: [
{ runtime: "pi", provider: "openai" },
{ runtime: "pi", provider: "google" },
],
}),
run: runFn,
});
expect(runtimeOrder).toEqual(["ccsdk", "pi", "pi"]);
expect(successOnThird).toBe(true);
});
it("throws when all runtimes fail", async () => {
const failoverError = Object.assign(new Error("All failed"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const runFn = vi.fn().mockRejectedValue(failoverError);
await expect(
runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "ccsdk",
fallbackRuntimes: [{ runtime: "pi" }],
}),
run: runFn,
}),
).rejects.toThrow();
});
it("does not deduplicate distinct slot configurations", async () => {
const attempts: string[] = [];
const failoverError = Object.assign(new Error("Failed"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const runFn = vi
.fn()
.mockImplementation(
async ({ runtime, provider }: { runtime: AgentRuntimeKind; provider: string }) => {
const key = `${runtime}:${provider}`;
attempts.push(key);
// Fail first attempt, succeed on second distinct slot
if (attempts.length < 2) throw failoverError;
return successResult;
},
);
// Two different providers for Pi - these should NOT be deduplicated
const result = await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "pi",
provider: "anthropic",
fallbackRuntimes: [{ runtime: "pi", provider: "openai" }],
}),
run: runFn,
});
// Both distinct slots should have been attempted
expect(attempts).toContain("pi:anthropic");
expect(attempts).toContain("pi:openai");
expect(attempts.length).toBe(2);
// Should have succeeded on second slot
expect(result.provider).toBe("openai");
});
});
describe("profile rotation", () => {
it("rotates through profiles on rate limit errors", async () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2", "profile-3"]);
const failoverError = Object.assign(new Error("Rate limit"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const profilesAttempted: (string | undefined)[] = [];
let attemptCount = 0;
const runFn = vi.fn().mockImplementation(async ({ profileId }: { profileId?: string }) => {
profilesAttempted.push(profileId);
attemptCount++;
if (attemptCount < 3) throw failoverError;
return successResult;
});
await runWithUnifiedFallback({
config: createConfig({ authProfiles: ["profile-1", "profile-2", "profile-3"] }),
run: runFn,
});
expect(profilesAttempted).toEqual(["profile-1", "profile-2", "profile-3"]);
});
it("marks profile as cooled down on auth error", async () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1"]);
const authError = Object.assign(new Error("Auth failed"), {
name: "FailoverError",
reason: "auth",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(authError as any);
let attemptCount = 0;
const runFn = vi.fn().mockImplementation(async () => {
attemptCount++;
if (attemptCount === 1) throw authError;
return successResult;
});
// Use CCSDK as fallback (different runtime) to avoid deduplication
await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "pi",
authProfiles: ["profile-1"],
fallbackRuntimes: [{ runtime: "ccsdk" }],
}),
cfg: {} as any,
run: runFn,
});
expect(markAuthProfileCooldown).toHaveBeenCalledWith(
expect.objectContaining({
profileId: "profile-1",
}),
);
});
it("marks profile as cooled down on billing error", async () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1"]);
const billingError = Object.assign(new Error("Billing error"), {
name: "FailoverError",
reason: "billing",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(billingError as any);
let attemptCount = 0;
const runFn = vi.fn().mockImplementation(async () => {
attemptCount++;
if (attemptCount === 1) throw billingError;
return successResult;
});
// Use CCSDK as fallback (different runtime) to avoid deduplication
await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "pi",
authProfiles: ["profile-1"],
fallbackRuntimes: [{ runtime: "ccsdk" }],
}),
cfg: {} as any,
run: runFn,
});
expect(markAuthProfileCooldown).toHaveBeenCalled();
});
it("skips slots when all profiles are in cooldown", async () => {
// Return profiles only for CCSDK/anthropic, empty for Pi/openai
vi.mocked(resolveAuthProfileOrder).mockImplementation(({ provider }) => {
return provider === "anthropic" ? ["profile-1", "profile-2"] : [];
});
// All anthropic profiles are in cooldown
vi.mocked(isProfileInCooldown).mockReturnValue(true);
const runFn = vi.fn().mockResolvedValue(successResult);
const result = await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "ccsdk",
provider: "anthropic",
// Pi with openai has no profiles, so no cooldown check
fallbackRuntimes: [{ runtime: "pi", provider: "openai" }],
}),
cfg: {} as any,
run: runFn,
});
// First slot (ccsdk/anthropic) skipped due to cooldown, second (pi/openai) succeeded
expect(result.attempts.some((a) => a.reason === "rate_limit")).toBe(true);
expect(result.runtime).toBe("pi");
expect(result.provider).toBe("openai");
});
});
describe("error handling", () => {
it("rethrows AbortError immediately", async () => {
const abortError = new Error("Aborted");
abortError.name = "AbortError";
const runFn = vi.fn().mockRejectedValue(abortError);
await expect(
runWithUnifiedFallback({
config: createConfig(),
run: runFn,
}),
).rejects.toThrow("Aborted");
});
it("rethrows non-failover errors", async () => {
const genericError = new Error("Unexpected error");
vi.mocked(isFailoverError).mockReturnValue(false);
vi.mocked(coerceToFailoverError).mockReturnValue(null);
const runFn = vi.fn().mockRejectedValue(genericError);
await expect(
runWithUnifiedFallback({
config: createConfig(),
run: runFn,
}),
).rejects.toThrow("Unexpected error");
});
it("calls onError callback for each failed attempt", async () => {
const failoverError = Object.assign(new Error("Failed"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const onError = vi.fn();
let attemptCount = 0;
const runFn = vi.fn().mockImplementation(async () => {
attemptCount++;
if (attemptCount < 2) throw failoverError;
return successResult;
});
// Use CCSDK as fallback (different runtime) to have 2 distinct slots
await runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "pi",
fallbackRuntimes: [{ runtime: "ccsdk" }],
}),
run: runFn,
onError,
});
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
runtime: "pi",
error: failoverError,
attempt: 1,
}),
);
});
it("includes error summary when all attempts fail", async () => {
const failoverError = Object.assign(new Error("Failed"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const runFn = vi.fn().mockRejectedValue(failoverError);
// Use CCSDK as fallback (different runtime) to have 2 distinct slots
// With 2+ attempts, the error summary format is used instead of rethrowing original
await expect(
runWithUnifiedFallback({
config: createConfig({
primaryRuntime: "pi",
fallbackRuntimes: [{ runtime: "ccsdk" }],
}),
run: runFn,
}),
).rejects.toThrow(/All runtimes failed/);
});
});
describe("single attempt behavior", () => {
it("throws original error when only one attempt fails", async () => {
const failoverError = Object.assign(new Error("Single failure"), {
name: "FailoverError",
reason: "rate_limit",
});
vi.mocked(isFailoverError).mockReturnValue(true);
vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any);
const runFn = vi.fn().mockRejectedValue(failoverError);
await expect(
runWithUnifiedFallback({
config: createConfig(), // No fallbacks
run: runFn,
}),
).rejects.toThrow("Single failure");
});
});
});
describe("isRuntimeSlotAvailable", () => {
it("returns true when no profiles are configured", () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue([]);
const slot: RuntimeSlot = { runtime: "pi" };
const result = isRuntimeSlotAvailable({
slot,
config: createConfig(),
});
expect(result).toBe(true);
});
it("returns true when at least one profile is not in cooldown", () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2"]);
vi.mocked(isProfileInCooldown).mockImplementation((_, id) => id === "profile-1");
const slot: RuntimeSlot = { runtime: "pi" };
const result = isRuntimeSlotAvailable({
slot,
config: createConfig({ authProfiles: ["profile-1", "profile-2"] }),
cfg: {} as any,
});
expect(result).toBe(true);
});
it("returns false when all profiles are in cooldown", () => {
vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2"]);
vi.mocked(isProfileInCooldown).mockReturnValue(true);
const slot: RuntimeSlot = { runtime: "pi" };
const result = isRuntimeSlotAvailable({
slot,
config: createConfig({ authProfiles: ["profile-1", "profile-2"] }),
cfg: {} as any,
});
expect(result).toBe(false);
});
});
});

View File

@ -292,16 +292,43 @@ export async function runAgentTurnWithFallback(params: {
shouldEmitToolOutput: params.shouldEmitToolOutput,
ownerNumbers: params.followupRun.run.ownerNumbers,
// Sandbox info (minimal version for CCSDK permission mode)
sandboxInfo: (() => {
const bashElevated = params.followupRun.run.bashElevated;
const elevatedAllowed = Boolean(bashElevated?.enabled && bashElevated?.allowed);
if (!elevatedAllowed) return undefined;
return {
enabled: true,
elevated: {
allowed: true,
defaultLevel: bashElevated?.defaultLevel ?? "off",
},
};
})(),
// Exec tool configuration (shared by both runtimes)
bashElevated: params.followupRun.run.bashElevated,
execOverrides: params.followupRun.run.execOverrides,
// Pi-specific options
piOptions: {
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
params.followupRun.run.provider,
),
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
},
// Provider session ID for session resumption (CCSDK and CLI providers)
providerSessionId: (() => {
const entry = params.getActiveSessionEntry();
const sessionId = getCliSessionId(entry, "ccsdk");
logVerbose(
`[PROVIDER-SESSION] Retrieving provider session ID: ${sessionId ?? "(none)"}, ` +
`cliSessionIds keys: ${entry?.cliSessionIds ? Object.keys(entry.cliSessionIds).join(", ") : "(none)"}`,
);
return sessionId;
})(),
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,

View File

@ -132,14 +132,30 @@ export async function runMemoryFlushIfNeeded(params: {
reasoningLevel: params.followupRun.run.reasoningLevel,
ownerNumbers: params.followupRun.run.ownerNumbers,
// Sandbox info (minimal version for CCSDK permission mode)
sandboxInfo: (() => {
const bashElevated = params.followupRun.run.bashElevated;
const elevatedAllowed = Boolean(bashElevated?.enabled && bashElevated?.allowed);
if (!elevatedAllowed) return undefined;
return {
enabled: true,
elevated: {
allowed: true,
defaultLevel: bashElevated?.defaultLevel ?? "off",
},
};
})(),
// Exec tool configuration (shared by both runtimes)
bashElevated: params.followupRun.run.bashElevated,
execOverrides: params.followupRun.run.execOverrides,
// Pi-specific options
piOptions: {
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
params.followupRun.run.provider,
),
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
},
// Fallback config

View File

@ -3,6 +3,7 @@ import fs from "node:fs";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { setCliSessionId } from "../../agents/cli-session.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
@ -15,6 +16,7 @@ import {
updateSessionStoreEntry,
} from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
@ -368,15 +370,29 @@ export async function runReplyAgent(params: {
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
const providerUsed =
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
const cliSessionId = isCliProvider(providerUsed, cfg)
? runResult.meta.agentMeta?.sessionId?.trim()
: undefined;
const contextTokensUsed =
agentCfgContextTokens ??
lookupContextTokens(modelUsed) ??
activeSessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
// Extract session ID for CLI providers or CCSDK runtime
// CLI providers use their provider name as key; CCSDK uses "ccsdk"
const isCcsdkRun = runResult.meta.agentMeta?.runtime === "ccsdk";
const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim();
const cliSessionIdProvider = isCcsdkRun
? "ccsdk"
: isCliProvider(providerUsed, cfg)
? undefined
: undefined;
const shouldStoreSessionId = isCcsdkRun || isCliProvider(providerUsed, cfg);
logVerbose(
`[PROVIDER-SESSION] Session ID detection: providerUsed=${providerUsed}, ` +
`isCcsdkRun=${isCcsdkRun}, shouldStore=${shouldStoreSessionId}, ` +
`sessionId=${cliSessionId ?? "(none)"}, providerKey=${cliSessionIdProvider ?? "(none)"}`,
);
await persistSessionUsageUpdate({
storePath,
sessionKey,
@ -385,9 +401,23 @@ export async function runReplyAgent(params: {
providerUsed,
contextTokensUsed,
systemPromptReport: runResult.meta.systemPromptReport,
cliSessionId,
cliSessionId: shouldStoreSessionId ? cliSessionId : undefined,
cliSessionIdProvider,
});
// Update local activeSessionEntry with the provider session ID so subsequent turns
// within the same conversation can access it via getActiveSessionEntry()
if (shouldStoreSessionId && cliSessionId && activeSessionEntry) {
const providerKey = cliSessionIdProvider ?? providerUsed ?? activeSessionEntry.modelProvider;
if (providerKey) {
setCliSessionId(activeSessionEntry, providerKey, cliSessionId);
logVerbose(
`[PROVIDER-SESSION] Stored provider session ID in activeSessionEntry: ${cliSessionId}, ` +
`provider key: ${providerKey}`,
);
}
}
// Drain any late tool/block deliveries before deciding there's "nothing to send".
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
// keep the typing indicator stuck.

View File

@ -165,11 +165,27 @@ export function createFollowupRunner(params: {
blockReplyBreak: queued.run.blockReplyBreak,
ownerNumbers: queued.run.ownerNumbers,
// Sandbox info (minimal version for CCSDK permission mode)
sandboxInfo: (() => {
const bashElevated = queued.run.bashElevated;
const elevatedAllowed = Boolean(bashElevated?.enabled && bashElevated?.allowed);
if (!elevatedAllowed) return undefined;
return {
enabled: true,
elevated: {
allowed: true,
defaultLevel: bashElevated?.defaultLevel ?? "off",
},
};
})(),
// Exec tool configuration (shared by both runtimes)
bashElevated: queued.run.bashElevated,
execOverrides: queued.run.execOverrides,
// Pi-specific options
piOptions: {
enforceFinalTag: queued.run.enforceFinalTag,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
},
// Fallback config

View File

@ -16,6 +16,8 @@ export async function persistSessionUsageUpdate(params: {
contextTokensUsed?: number;
systemPromptReport?: SessionSystemPromptReport;
cliSessionId?: string;
/** Provider key for storing cliSessionId (defaults to providerUsed). Use "ccsdk" for CCSDK sessions. */
cliSessionIdProvider?: string;
logLabel?: string;
}): Promise<void> {
const { storePath, sessionKey } = params;
@ -42,7 +44,8 @@ export async function persistSessionUsageUpdate(params: {
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
updatedAt: Date.now(),
};
const cliProvider = params.providerUsed ?? entry.modelProvider;
const cliProvider =
params.cliSessionIdProvider ?? params.providerUsed ?? entry.modelProvider;
if (params.cliSessionId && cliProvider) {
const nextEntry = { ...entry, ...patch };
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
@ -74,7 +77,8 @@ export async function persistSessionUsageUpdate(params: {
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
updatedAt: Date.now(),
};
const cliProvider = params.providerUsed ?? entry.modelProvider;
const cliProvider =
params.cliSessionIdProvider ?? params.providerUsed ?? entry.modelProvider;
if (params.cliSessionId && cliProvider) {
const nextEntry = { ...entry, ...patch };
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);

View File

@ -447,10 +447,9 @@ export async function agentCommand(
replyToMode: runContext.replyToMode,
hasRepliedRef: runContext.hasRepliedRef,
// Pi-specific options
piOptions: {
clientTools: opts.clientTools,
},
// Client-provided tools (OpenResponses hosted tools)
// Now at top level so both Pi and CCSDK runtimes can use them
clientTools: opts.clientTools,
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),

View File

@ -1,4 +1,4 @@
import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js";
import type { ClientToolDefinition } from "../../agents/agent-runtime.js";
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
/** Image content block for Claude API multimodal messages. */

View File

@ -71,3 +71,25 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) {
if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
return path.resolve(store);
}
/**
* Resolve the Claude Code SDK config directory for a given agent.
*
* This directory is passed to the SDK via the CLAUDE_CONFIG_DIR environment
* variable to ensure Moltbot agent sessions are stored separately from
* standard Claude Code CLI sessions.
*
* Using `.claude/` allows per-agent customization of other Claude Code
* features like settings.json, CLAUDE.md, skills, commands, etc.
*
* Structure: ~/.clawdbot/agents/{agentId}/.claude/
*/
export function resolveCcSdkConfigDirForAgent(
agentId?: string,
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const root = resolveStateDir(env, homedir);
const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID);
return path.join(root, "agents", id, ".claude");
}