mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 12:44:59 +00:00
refactor(core): extract shared dedup helpers
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import {
|
||||
addAllowlistEntry,
|
||||
@@ -20,11 +19,12 @@ import {
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
createDefaultExecApprovalRequestContext,
|
||||
resolveBaseExecApprovalDecision,
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||
createApprovalSlug,
|
||||
emitExecSystemEvent,
|
||||
@@ -138,16 +138,24 @@ export async function processGatewayAllowlist(
|
||||
}
|
||||
|
||||
if (requiresAsk) {
|
||||
const approvalId = crypto.randomUUID();
|
||||
const approvalSlug = createApprovalSlug(approvalId);
|
||||
const contextKey = `exec:${approvalId}`;
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
contextKey,
|
||||
noticeSeconds,
|
||||
warningText,
|
||||
expiresAtMs: defaultExpiresAtMs,
|
||||
preResolvedDecision: defaultPreResolvedDecision,
|
||||
} = createDefaultExecApprovalRequestContext({
|
||||
warnings: params.warnings,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
createApprovalSlug,
|
||||
});
|
||||
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
|
||||
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
|
||||
const effectiveTimeout =
|
||||
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
|
||||
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
|
||||
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
let preResolvedDecision: string | null | undefined;
|
||||
let expiresAtMs = defaultExpiresAtMs;
|
||||
let preResolvedDecision = defaultPreResolvedDecision;
|
||||
|
||||
// Register first so the returned approval ID is actionable immediately.
|
||||
const registration = await registerExecApprovalRequestForHostOrThrow({
|
||||
@@ -184,24 +192,19 @@ export async function processGatewayAllowlist(
|
||||
return;
|
||||
}
|
||||
|
||||
let approvedByAsk = false;
|
||||
let deniedReason: string | null = null;
|
||||
const baseDecision = resolveBaseExecApprovalDecision({
|
||||
decision,
|
||||
askFallback,
|
||||
obfuscationDetected: obfuscation.detected,
|
||||
});
|
||||
let approvedByAsk = baseDecision.approvedByAsk;
|
||||
let deniedReason = baseDecision.deniedReason;
|
||||
|
||||
if (decision === "deny") {
|
||||
deniedReason = "user-denied";
|
||||
} else if (!decision) {
|
||||
if (obfuscation.detected) {
|
||||
deniedReason = "approval-timeout (obfuscation-detected)";
|
||||
} else if (askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
} else if (askFallback === "allowlist") {
|
||||
if (!analysisOk || !allowlistSatisfied) {
|
||||
deniedReason = "approval-timeout (allowlist-miss)";
|
||||
} else {
|
||||
approvedByAsk = true;
|
||||
}
|
||||
if (baseDecision.timedOut && askFallback === "allowlist") {
|
||||
if (!analysisOk || !allowlistSatisfied) {
|
||||
deniedReason = "approval-timeout (allowlist-miss)";
|
||||
} else {
|
||||
deniedReason = "approval-timeout";
|
||||
approvedByAsk = true;
|
||||
}
|
||||
} else if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
|
||||
@@ -18,14 +18,12 @@ import {
|
||||
registerExecApprovalRequestForHostOrThrow,
|
||||
} from "./bash-tools.exec-approval-request.js";
|
||||
import {
|
||||
createDefaultExecApprovalRequestContext,
|
||||
resolveBaseExecApprovalDecision,
|
||||
resolveApprovalDecisionOrUndefined,
|
||||
resolveExecHostApprovalContext,
|
||||
} from "./bash-tools.exec-host-shared.js";
|
||||
import {
|
||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
createApprovalSlug,
|
||||
emitExecSystemEvent,
|
||||
} from "./bash-tools.exec-runtime.js";
|
||||
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import { callGatewayTool } from "./tools/gateway.js";
|
||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||
@@ -209,13 +207,21 @@ export async function executeNodeHostCommand(
|
||||
}) satisfies Record<string, unknown>;
|
||||
|
||||
if (requiresAsk) {
|
||||
const approvalId = crypto.randomUUID();
|
||||
const approvalSlug = createApprovalSlug(approvalId);
|
||||
const contextKey = `exec:${approvalId}`;
|
||||
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
|
||||
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
|
||||
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||
let preResolvedDecision: string | null | undefined;
|
||||
const {
|
||||
approvalId,
|
||||
approvalSlug,
|
||||
contextKey,
|
||||
noticeSeconds,
|
||||
warningText,
|
||||
expiresAtMs: defaultExpiresAtMs,
|
||||
preResolvedDecision: defaultPreResolvedDecision,
|
||||
} = createDefaultExecApprovalRequestContext({
|
||||
warnings: params.warnings,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
createApprovalSlug,
|
||||
});
|
||||
let expiresAtMs = defaultExpiresAtMs;
|
||||
let preResolvedDecision = defaultPreResolvedDecision;
|
||||
|
||||
// Register first so the returned approval ID is actionable immediately.
|
||||
const registration = await registerExecApprovalRequestForHostOrThrow({
|
||||
@@ -252,23 +258,17 @@ export async function executeNodeHostCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
let approvedByAsk = false;
|
||||
const baseDecision = resolveBaseExecApprovalDecision({
|
||||
decision,
|
||||
askFallback,
|
||||
obfuscationDetected: obfuscation.detected,
|
||||
});
|
||||
let approvedByAsk = baseDecision.approvedByAsk;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
let deniedReason: string | null = null;
|
||||
let deniedReason = baseDecision.deniedReason;
|
||||
|
||||
if (decision === "deny") {
|
||||
deniedReason = "user-denied";
|
||||
} else if (!decision) {
|
||||
if (obfuscation.detected) {
|
||||
deniedReason = "approval-timeout (obfuscation-detected)";
|
||||
} else if (askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else if (askFallback === "allowlist") {
|
||||
// Defer allowlist enforcement to the node host.
|
||||
} else {
|
||||
deniedReason = "approval-timeout";
|
||||
}
|
||||
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
|
||||
approvalDecision = "allow-once";
|
||||
} else if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
type ExecSecurity,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
|
||||
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js";
|
||||
|
||||
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
|
||||
|
||||
@@ -16,6 +18,110 @@ export type ExecHostApprovalContext = {
|
||||
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
|
||||
};
|
||||
|
||||
export type ExecApprovalPendingState = {
|
||||
warningText: string;
|
||||
expiresAtMs: number;
|
||||
preResolvedDecision: string | null | undefined;
|
||||
};
|
||||
|
||||
export type ExecApprovalRequestState = ExecApprovalPendingState & {
|
||||
noticeSeconds: number;
|
||||
};
|
||||
|
||||
export function createExecApprovalPendingState(params: {
|
||||
warnings: string[];
|
||||
timeoutMs: number;
|
||||
}): ExecApprovalPendingState {
|
||||
return {
|
||||
warningText: params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "",
|
||||
expiresAtMs: Date.now() + params.timeoutMs,
|
||||
preResolvedDecision: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function createExecApprovalRequestState(params: {
|
||||
warnings: string[];
|
||||
timeoutMs: number;
|
||||
approvalRunningNoticeMs: number;
|
||||
}): ExecApprovalRequestState {
|
||||
const pendingState = createExecApprovalPendingState({
|
||||
warnings: params.warnings,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return {
|
||||
...pendingState,
|
||||
noticeSeconds: Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createExecApprovalRequestContext(params: {
|
||||
warnings: string[];
|
||||
timeoutMs: number;
|
||||
approvalRunningNoticeMs: number;
|
||||
createApprovalSlug: (approvalId: string) => string;
|
||||
}): ExecApprovalRequestState & {
|
||||
approvalId: string;
|
||||
approvalSlug: string;
|
||||
contextKey: string;
|
||||
} {
|
||||
const approvalId = crypto.randomUUID();
|
||||
const pendingState = createExecApprovalRequestState({
|
||||
warnings: params.warnings,
|
||||
timeoutMs: params.timeoutMs,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
});
|
||||
return {
|
||||
...pendingState,
|
||||
approvalId,
|
||||
approvalSlug: params.createApprovalSlug(approvalId),
|
||||
contextKey: `exec:${approvalId}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultExecApprovalRequestContext(params: {
|
||||
warnings: string[];
|
||||
approvalRunningNoticeMs: number;
|
||||
createApprovalSlug: (approvalId: string) => string;
|
||||
}) {
|
||||
return createExecApprovalRequestContext({
|
||||
warnings: params.warnings,
|
||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
|
||||
createApprovalSlug: params.createApprovalSlug,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBaseExecApprovalDecision(params: {
|
||||
decision: string | null;
|
||||
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
|
||||
obfuscationDetected: boolean;
|
||||
}): {
|
||||
approvedByAsk: boolean;
|
||||
deniedReason: string | null;
|
||||
timedOut: boolean;
|
||||
} {
|
||||
if (params.decision === "deny") {
|
||||
return { approvedByAsk: false, deniedReason: "user-denied", timedOut: false };
|
||||
}
|
||||
if (!params.decision) {
|
||||
if (params.obfuscationDetected) {
|
||||
return {
|
||||
approvedByAsk: false,
|
||||
deniedReason: "approval-timeout (obfuscation-detected)",
|
||||
timedOut: true,
|
||||
};
|
||||
}
|
||||
if (params.askFallback === "full") {
|
||||
return { approvedByAsk: true, deniedReason: null, timedOut: true };
|
||||
}
|
||||
if (params.askFallback === "deny") {
|
||||
return { approvedByAsk: false, deniedReason: "approval-timeout", timedOut: true };
|
||||
}
|
||||
return { approvedByAsk: false, deniedReason: null, timedOut: true };
|
||||
}
|
||||
return { approvedByAsk: false, deniedReason: null, timedOut: false };
|
||||
}
|
||||
|
||||
export function resolveExecHostApprovalContext(params: {
|
||||
agentId?: string;
|
||||
security: ExecSecurity;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
|
||||
import { type ExecHost } from "../infra/exec-approvals.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
|
||||
@@ -11,6 +11,11 @@ import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||
export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js";
|
||||
export {
|
||||
normalizeExecAsk,
|
||||
normalizeExecHost,
|
||||
normalizeExecSecurity,
|
||||
} from "../infra/exec-approvals.js";
|
||||
import { logWarn } from "../logger.js";
|
||||
import type { ManagedRun } from "../process/supervisor/index.js";
|
||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||
@@ -156,30 +161,6 @@ export type ExecProcessHandle = {
|
||||
kill: () => void;
|
||||
};
|
||||
|
||||
export function normalizeExecHost(value?: string | null): ExecHost | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeExecAsk(value?: string | null): ExecAsk | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
|
||||
return normalized as ExecAsk;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderExecHostLabel(host: ExecHost) {
|
||||
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseBooleanValue } from "../utils/boolean.js";
|
||||
import { safeJsonStringify } from "../utils/safe-json.js";
|
||||
import { redactImageDataForDiagnostics } from "./payload-redaction.js";
|
||||
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
|
||||
import { buildAgentTraceBase } from "./trace-base.js";
|
||||
|
||||
export type CacheTraceStage =
|
||||
| "session:loaded"
|
||||
@@ -173,15 +174,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
|
||||
const writer = params.writer ?? getWriter(cfg.filePath);
|
||||
let seq = 0;
|
||||
|
||||
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = buildAgentTraceBase(params);
|
||||
|
||||
const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => {
|
||||
const event: CacheTraceEvent = {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
pruneHistoryForContextShare,
|
||||
splitMessagesByTokenShare,
|
||||
} from "./compaction.js";
|
||||
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
|
||||
|
||||
function makeMessage(id: number, size: number): AgentMessage {
|
||||
return {
|
||||
@@ -24,26 +25,15 @@ function makeAssistantToolCall(
|
||||
toolCallId: string,
|
||||
text = "x".repeat(4000),
|
||||
): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
return makeAgentAssistantMessage({
|
||||
content: [
|
||||
{ type: "text", text },
|
||||
{ type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} },
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolResult(timestamp: number, toolCallId: string, text: string): ToolResultMessage {
|
||||
@@ -229,27 +219,16 @@ describe("pruneHistoryForContextShare", () => {
|
||||
// all corresponding tool_results should be removed from kept messages
|
||||
const messages: AgentMessage[] = [
|
||||
// Chunk 1 (will be dropped) - contains multiple tool_use blocks
|
||||
{
|
||||
role: "assistant",
|
||||
makeAgentAssistantMessage({
|
||||
content: [
|
||||
{ type: "text", text: "x".repeat(4000) },
|
||||
{ type: "toolCall", id: "call_a", name: "tool_a", arguments: {} },
|
||||
{ type: "toolCall", id: "call_b", name: "tool_b", arguments: {} },
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
},
|
||||
}),
|
||||
// Chunk 2 (will be kept) - contains orphaned tool_results
|
||||
makeToolResult(2, "call_a", "result_a"),
|
||||
makeToolResult(3, "call_b", "result_b"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
|
||||
|
||||
const piCodingAgentMocks = vi.hoisted(() => ({
|
||||
generateSummary: vi.fn(async () => "summary"),
|
||||
@@ -21,23 +22,12 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js";
|
||||
|
||||
function makeAssistantToolCall(timestamp: number): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
return makeAgentAssistantMessage({
|
||||
content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> {
|
||||
|
||||
@@ -1,33 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
function mockContextModuleDeps(loadConfigImpl: () => unknown) {
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: loadConfigImpl,
|
||||
}));
|
||||
vi.doMock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.doMock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
|
||||
}));
|
||||
vi.doMock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({})),
|
||||
discoverModels: vi.fn(() => ({
|
||||
getAll: () => [],
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("lookupContextTokens", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns configured model context window on first lookup", async () => {
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
models: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
|
||||
},
|
||||
mockContextModuleDeps(() => ({
|
||||
models: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.doMock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.doMock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
|
||||
}));
|
||||
vi.doMock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({})),
|
||||
discoverModels: vi.fn(() => ({
|
||||
getAll: () => [],
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const { lookupContextTokens } = await import("./context.js");
|
||||
@@ -36,21 +40,7 @@ describe("lookupContextTokens", () => {
|
||||
|
||||
it("does not skip eager warmup when --profile is followed by -- terminator", async () => {
|
||||
const loadConfigMock = vi.fn(() => ({ models: {} }));
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
}));
|
||||
vi.doMock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.doMock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
|
||||
}));
|
||||
vi.doMock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({})),
|
||||
discoverModels: vi.fn(() => ({
|
||||
getAll: () => [],
|
||||
})),
|
||||
}));
|
||||
mockContextModuleDeps(loadConfigMock);
|
||||
|
||||
const argvSnapshot = process.argv;
|
||||
process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"];
|
||||
@@ -79,21 +69,7 @@ describe("lookupContextTokens", () => {
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
}));
|
||||
vi.doMock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.doMock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
|
||||
}));
|
||||
vi.doMock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({})),
|
||||
discoverModels: vi.fn(() => ({
|
||||
getAll: () => [],
|
||||
})),
|
||||
}));
|
||||
mockContextModuleDeps(loadConfigMock);
|
||||
|
||||
const argvSnapshot = process.argv;
|
||||
process.argv = ["node", "openclaw", "config", "validate"];
|
||||
|
||||
@@ -19,6 +19,33 @@ function throwPathEscapesBoundary(params: {
|
||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
||||
}
|
||||
|
||||
function validateRelativePathWithinBoundary(params: {
|
||||
relativePath: string;
|
||||
isAbsolutePath: (path: string) => boolean;
|
||||
options?: RelativePathOptions;
|
||||
rootResolved: string;
|
||||
candidate: string;
|
||||
}): string {
|
||||
if (params.relativePath === "" || params.relativePath === ".") {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved: params.rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) {
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved: params.rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
return params.relativePath;
|
||||
}
|
||||
|
||||
function toRelativePathUnderRoot(params: {
|
||||
root: string;
|
||||
candidate: string;
|
||||
@@ -35,47 +62,44 @@ function toRelativePathUnderRoot(params: {
|
||||
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
|
||||
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
|
||||
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
||||
if (relative === "" || relative === ".") {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
return relative;
|
||||
return validateRelativePathWithinBoundary({
|
||||
relativePath: relative,
|
||||
isAbsolutePath: path.win32.isAbsolute,
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
|
||||
const rootResolved = path.resolve(params.root);
|
||||
const resolvedCandidate = path.resolve(resolvedInput);
|
||||
const relative = path.relative(rootResolved, resolvedCandidate);
|
||||
if (relative === "" || relative === ".") {
|
||||
if (params.options?.allowRoot) {
|
||||
return "";
|
||||
}
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
throwPathEscapesBoundary({
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
return relative;
|
||||
return validateRelativePathWithinBoundary({
|
||||
relativePath: relative,
|
||||
isAbsolutePath: path.isAbsolute,
|
||||
options: params.options,
|
||||
rootResolved,
|
||||
candidate: params.candidate,
|
||||
});
|
||||
}
|
||||
|
||||
function toRelativeBoundaryPath(params: {
|
||||
root: string;
|
||||
candidate: string;
|
||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">;
|
||||
boundaryLabel: string;
|
||||
includeRootInError?: boolean;
|
||||
}): string {
|
||||
return toRelativePathUnderRoot({
|
||||
root: params.root,
|
||||
candidate: params.candidate,
|
||||
options: {
|
||||
allowRoot: params.options?.allowRoot,
|
||||
cwd: params.options?.cwd,
|
||||
boundaryLabel: params.boundaryLabel,
|
||||
includeRootInError: params.includeRootInError,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toRelativeWorkspacePath(
|
||||
@@ -83,14 +107,11 @@ export function toRelativeWorkspacePath(
|
||||
candidate: string,
|
||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
||||
): string {
|
||||
return toRelativePathUnderRoot({
|
||||
return toRelativeBoundaryPath({
|
||||
root,
|
||||
candidate,
|
||||
options: {
|
||||
allowRoot: options?.allowRoot,
|
||||
cwd: options?.cwd,
|
||||
boundaryLabel: "workspace root",
|
||||
},
|
||||
options,
|
||||
boundaryLabel: "workspace root",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,15 +120,12 @@ export function toRelativeSandboxPath(
|
||||
candidate: string,
|
||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
||||
): string {
|
||||
return toRelativePathUnderRoot({
|
||||
return toRelativeBoundaryPath({
|
||||
root,
|
||||
candidate,
|
||||
options: {
|
||||
allowRoot: options?.allowRoot,
|
||||
cwd: options?.cwd,
|
||||
boundaryLabel: "sandbox root",
|
||||
includeRootInError: true,
|
||||
},
|
||||
options,
|
||||
boundaryLabel: "sandbox root",
|
||||
includeRootInError: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,17 @@ import {
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
|
||||
import {
|
||||
castAgentMessages,
|
||||
makeAgentAssistantMessage,
|
||||
} from "./test-helpers/agent-message-fixtures.js";
|
||||
|
||||
let testTimestamp = 1;
|
||||
const nextTimestamp = () => testTimestamp++;
|
||||
|
||||
function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessage> {
|
||||
return [
|
||||
{
|
||||
role: "assistant",
|
||||
makeAgentAssistantMessage({
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
@@ -22,20 +24,10 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
|
||||
arguments: { path: "package.json" },
|
||||
},
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_456",
|
||||
@@ -47,6 +39,27 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
|
||||
];
|
||||
}
|
||||
|
||||
function makeEmptyAssistantErrorMessage(): AssistantMessage {
|
||||
return makeAgentAssistantMessage({
|
||||
stopReason: "error",
|
||||
content: [],
|
||||
model: "gpt-5.2",
|
||||
timestamp: nextTimestamp(),
|
||||
}) satisfies AssistantMessage;
|
||||
}
|
||||
|
||||
function makeOpenAiResponsesAssistantMessage(
|
||||
content: AssistantMessage["content"],
|
||||
stopReason: AssistantMessage["stopReason"] = "toolUse",
|
||||
): AssistantMessage {
|
||||
return makeAgentAssistantMessage({
|
||||
content,
|
||||
model: "gpt-5.2",
|
||||
stopReason,
|
||||
timestamp: nextTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
function expectToolCallAndResultIds(out: AgentMessage[], expectedId: string) {
|
||||
const assistant = out[0];
|
||||
expect(assistant.role).toBe("assistant");
|
||||
@@ -95,23 +108,9 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
|
||||
it("does not synthesize tool call input when missing", async () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read" }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
makeOpenAiResponsesAssistantMessage([
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
]),
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
@@ -124,26 +123,10 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
|
||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
makeOpenAiResponsesAssistantMessage([
|
||||
{ type: "text", text: "" },
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
]),
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
@@ -189,33 +172,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
});
|
||||
|
||||
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_456",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: nextTimestamp(),
|
||||
},
|
||||
]);
|
||||
const input = makeToolCallResultPairInput();
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||
sanitizeMode: "images-only",
|
||||
@@ -297,39 +254,11 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
const input = castAgentMessages([
|
||||
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
content: [],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: nextTimestamp(),
|
||||
} satisfies AssistantMessage,
|
||||
...makeEmptyAssistantErrorMessage(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
content: [],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: nextTimestamp(),
|
||||
} satisfies AssistantMessage,
|
||||
...makeEmptyAssistantErrorMessage(),
|
||||
},
|
||||
]);
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||
import type { ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||
import { sanitizeSessionHistory } from "./google.js";
|
||||
|
||||
function makeAssistantToolCall(timestamp: number): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sanitizeSessionHistory toolResult details stripping", () => {
|
||||
it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => {
|
||||
const sm = SessionManager.inMemory();
|
||||
|
||||
const messages: AgentMessage[] = [
|
||||
makeAssistantToolCall(1),
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }],
|
||||
model: "gpt-5.2",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
}),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||
import {
|
||||
truncateToolResultText,
|
||||
truncateToolResultMessage,
|
||||
@@ -35,23 +36,12 @@ function makeUserMessage(text: string): UserMessage {
|
||||
}
|
||||
|
||||
function makeAssistantMessage(text: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
return makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: nextTimestamp(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("truncateToolResultText", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js";
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
runner: {
|
||||
@@ -75,17 +76,7 @@ function createToolHandlerCtx() {
|
||||
hookRunner: hookMocks.runner,
|
||||
state: {
|
||||
toolMetaById: new Map<string, unknown>(),
|
||||
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
|
||||
toolSummaryById: new Set<string>(),
|
||||
lastToolError: undefined,
|
||||
pendingMessagingTexts: new Map<string, string>(),
|
||||
pendingMessagingTargets: new Map<string, unknown>(),
|
||||
pendingMessagingMediaUrls: new Map<string, string[]>(),
|
||||
messagingToolSentTexts: [] as string[],
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
messagingToolSentTargets: [] as unknown[],
|
||||
blockBuffer: "",
|
||||
...createBaseToolHandlerState(),
|
||||
successfulCronAdds: 0,
|
||||
},
|
||||
log: { debug: vi.fn(), warn: vi.fn() },
|
||||
@@ -247,7 +238,10 @@ describe("after_tool_call fires exactly once in embedded runs", () => {
|
||||
result: { content: [{ type: "text", text: "ok" }] },
|
||||
});
|
||||
|
||||
expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(toolCallId);
|
||||
expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(
|
||||
toolCallId,
|
||||
"integration-test",
|
||||
);
|
||||
const event = (hookMocks.runner.runAfterToolCall as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0]?.[0] as { params?: unknown } | undefined;
|
||||
expect(event?.params).toEqual(adjusted);
|
||||
|
||||
15
src/agents/pi-tool-handler-state.test-helpers.ts
Normal file
15
src/agents/pi-tool-handler-state.test-helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function createBaseToolHandlerState() {
|
||||
return {
|
||||
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
|
||||
toolSummaryById: new Set<string>(),
|
||||
lastToolError: undefined,
|
||||
pendingMessagingTexts: new Map<string, string>(),
|
||||
pendingMessagingTargets: new Map<string, unknown>(),
|
||||
pendingMessagingMediaUrls: new Map<string, string[]>(),
|
||||
messagingToolSentTexts: [] as string[],
|
||||
messagingToolSentTextsNormalized: [] as string[],
|
||||
messagingToolSentMediaUrls: [] as string[],
|
||||
messagingToolSentTargets: [] as unknown[],
|
||||
blockBuffer: "",
|
||||
};
|
||||
}
|
||||
@@ -112,10 +112,12 @@ function createSlugBase(words = 2) {
|
||||
return parts.join("-");
|
||||
}
|
||||
|
||||
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
||||
const isIdTaken = isTaken ?? (() => false);
|
||||
function createAvailableSlug(
|
||||
words: number,
|
||||
isIdTaken: (id: string) => boolean,
|
||||
): string | undefined {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const base = createSlugBase(2);
|
||||
const base = createSlugBase(words);
|
||||
if (!isIdTaken(base)) {
|
||||
return base;
|
||||
}
|
||||
@@ -126,17 +128,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const base = createSlugBase(3);
|
||||
if (!isIdTaken(base)) {
|
||||
return base;
|
||||
}
|
||||
for (let i = 2; i <= 12; i += 1) {
|
||||
const candidate = `${base}-${i}`;
|
||||
if (!isIdTaken(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
||||
const isIdTaken = isTaken ?? (() => false);
|
||||
const twoWord = createAvailableSlug(2, isIdTaken);
|
||||
if (twoWord) {
|
||||
return twoWord;
|
||||
}
|
||||
const threeWord = createAvailableSlug(3, isIdTaken);
|
||||
if (threeWord) {
|
||||
return threeWord;
|
||||
}
|
||||
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
|
||||
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
|
||||
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
||||
import {
|
||||
applyOpenClawManifestInstallCommonFields,
|
||||
getFrontmatterString,
|
||||
normalizeStringList,
|
||||
parseOpenClawManifestInstallBase,
|
||||
@@ -113,19 +114,12 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
||||
return undefined;
|
||||
}
|
||||
const { raw } = parsed;
|
||||
const spec: SkillInstallSpec = {
|
||||
kind: parsed.kind as SkillInstallSpec["kind"],
|
||||
};
|
||||
|
||||
if (parsed.id) {
|
||||
spec.id = parsed.id;
|
||||
}
|
||||
if (parsed.label) {
|
||||
spec.label = parsed.label;
|
||||
}
|
||||
if (parsed.bins) {
|
||||
spec.bins = parsed.bins;
|
||||
}
|
||||
const spec = applyOpenClawManifestInstallCommonFields<SkillInstallSpec>(
|
||||
{
|
||||
kind: parsed.kind as SkillInstallSpec["kind"],
|
||||
},
|
||||
parsed,
|
||||
);
|
||||
const osList = normalizeStringList(raw.os);
|
||||
if (osList.length > 0) {
|
||||
spec.os = osList;
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai";
|
||||
|
||||
const ZERO_USAGE: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
|
||||
|
||||
export function castAgentMessage(message: unknown): AgentMessage {
|
||||
return message as AgentMessage;
|
||||
@@ -42,7 +28,7 @@ export function makeAgentAssistantMessage(
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "test-model",
|
||||
usage: ZERO_USAGE,
|
||||
usage: ZERO_USAGE_FIXTURE,
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
|
||||
@@ -1,19 +1,5 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
|
||||
const ZERO_USAGE: AssistantMessage["usage"] = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
|
||||
|
||||
export function makeAssistantMessageFixture(
|
||||
overrides: Partial<AssistantMessage> = {},
|
||||
@@ -24,7 +10,7 @@ export function makeAssistantMessageFixture(
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "test-model",
|
||||
usage: ZERO_USAGE,
|
||||
usage: ZERO_USAGE_FIXTURE,
|
||||
timestamp: 0,
|
||||
stopReason: "error",
|
||||
errorMessage: errorText,
|
||||
|
||||
16
src/agents/test-helpers/usage-fixtures.ts
Normal file
16
src/agents/test-helpers/usage-fixtures.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Usage } from "@mariozechner/pi-ai";
|
||||
|
||||
export const ZERO_USAGE_FIXTURE: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
21
src/agents/trace-base.ts
Normal file
21
src/agents/trace-base.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type AgentTraceBase = {
|
||||
runId?: string;
|
||||
sessionId?: string;
|
||||
sessionKey?: string;
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
modelApi?: string | null;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
export function buildAgentTraceBase(params: AgentTraceBase): AgentTraceBase {
|
||||
return {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
modelApi: params.modelApi,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user