mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(llm): preserve structured tool result replay
Preserve structured tool-result replay text across provider transports while keeping provider-facing redaction and Anthropic media ordering intact. Thanks @snowzlmbot!
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
4f41a4ade7eadd84724ef2b951734c3c7573ac23f621a79b88976a15cb114f4c plugin-sdk-api-baseline.json
|
||||
14991eb87ea7b402c11c85bd2c2d817f2dfe2a45f695922cb7232327323e5803 plugin-sdk-api-baseline.jsonl
|
||||
c52e9007a94f19e63663495fb7e54824f9c29c4ebe403ea3b1a4e75f4ce8cedf plugin-sdk-api-baseline.json
|
||||
abbfc139068a2f98ecb7759d82597293cbcdba77b5036e8a2e9f07040834eb6d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -621,7 +621,7 @@ releases.
|
||||
| `plugin-sdk/provider-tools` | Provider tool/schema compat helpers | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
|
||||
| `plugin-sdk/provider-usage` | Provider usage helpers | `fetchClaudeUsage`, `fetchGeminiUsage`, `fetchGithubCopilotUsage`, and other provider usage helpers |
|
||||
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
| `plugin-sdk/provider-transport-runtime` | Provider transport helpers | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
|
||||
| `plugin-sdk/provider-transport-runtime` | Provider transport helpers | Native provider transport helpers such as guarded fetch, tool-result text extraction, transport message transforms, and writable transport event streams |
|
||||
| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` |
|
||||
| `plugin-sdk/media-runtime` | Shared media helpers | Media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
|
||||
| `plugin-sdk/media-generation-runtime` | Shared media-generation helpers | Shared failover helpers, candidate selection, and missing-model messaging for image/video/music generation |
|
||||
|
||||
@@ -165,7 +165,7 @@ and pairing-path families.
|
||||
| `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` |
|
||||
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
|
||||
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, `normalizeOpenAICompatibleReasoningPayload`, `setQwenChatTemplateThinking`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
|
||||
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
|
||||
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, tool-result text extraction, transport message transforms, and writable transport event streams |
|
||||
| `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
|
||||
| `plugin-sdk/global-singleton` | Process-local singleton/map/cache helpers |
|
||||
| `plugin-sdk/group-activation` | Narrow group activation mode and command parsing helpers |
|
||||
|
||||
@@ -2166,6 +2166,173 @@ describe("google transport stream", () => {
|
||||
expect(params.contents).toEqual([{ role: "user", parts: [{ text: " " }] }]);
|
||||
});
|
||||
|
||||
it("serializes structured-only Google tool results before fallback", () => {
|
||||
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
|
||||
messages: [
|
||||
googleToolCallAssistantTurn(),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [
|
||||
{
|
||||
type: "json",
|
||||
value: { city: "Paris", temperatureC: 21 },
|
||||
apiToken: "secret-token-123",
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const functionResponse = (params.contents[1] as GoogleTestContentTurn).parts[0]
|
||||
.functionResponse as { response: { output: string } };
|
||||
|
||||
expect(functionResponse).toMatchObject({ name: "lookup" });
|
||||
expect(functionResponse.response.output).toContain('"city":"Paris"');
|
||||
expect(functionResponse.response.output).toContain('"temperatureC":21');
|
||||
expect(functionResponse.response.output).toContain('"apiToken":"');
|
||||
expect(functionResponse.response.output).not.toContain("secret-token-123");
|
||||
});
|
||||
|
||||
it("keeps explicit Google tool-result text before structured fallback", () => {
|
||||
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
|
||||
messages: [
|
||||
googleToolCallAssistantTurn(),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [
|
||||
{ type: "json", value: { ignored: true } },
|
||||
{ type: "text", text: "explicit result" },
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
expect(params.contents[1]).toMatchObject({
|
||||
parts: [{ functionResponse: { response: { output: "explicit result" } } }],
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts opaque and binary structured Google tool-result fields", () => {
|
||||
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
|
||||
messages: [
|
||||
googleToolCallAssistantTurn(),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
mimeType: "image/png",
|
||||
data: "abcdef",
|
||||
encrypted_content: "opaque",
|
||||
text: "data:image/png;base64,abcdef",
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const functionResponse = (params.contents[1] as GoogleTestContentTurn).parts[0]
|
||||
.functionResponse as { response: { output: string } };
|
||||
|
||||
expect(functionResponse.response.output).toContain('"data":"[binary data omitted: 6 chars]"');
|
||||
expect(functionResponse.response.output).toContain(
|
||||
'"encrypted_content":"[omitted encrypted_content]"',
|
||||
);
|
||||
expect(functionResponse.response.output).toContain('"text":"[inline data URI: 23 chars]"');
|
||||
});
|
||||
|
||||
it("uses shared structured redaction for Google tool-result fields", () => {
|
||||
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
|
||||
messages: [
|
||||
googleToolCallAssistantTurn(),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [
|
||||
{
|
||||
type: "json",
|
||||
privateKey: "leaked-private-key-value-12345",
|
||||
private_key: "leaked-private-key-snake-12345",
|
||||
key: "leaked-generic-key-value-12345",
|
||||
keyMaterial: "leaked-key-material-value-12345",
|
||||
jwt: "leaked-jwt-value-1234567890",
|
||||
session: "leaked-session-value-123456",
|
||||
code: "code-value-1234567890",
|
||||
error: { code: "ERR_VISIBLE_GOOGLE_CODE" },
|
||||
oauth: { code: "OPAQUEGOOGLECODE1234567890" },
|
||||
providerError: { error: { code: "ERR_VISIBLE_PROVIDER_GOOGLE_CODE" } },
|
||||
signature: "leaked-signature-value-12345",
|
||||
cookie: "leaked-cookie-value-123456",
|
||||
"set-cookie": "leaked-set-cookie-value-12345",
|
||||
paymentCredential: "leaked-payment-credential-12345",
|
||||
cardNumber: "41111111111111112222",
|
||||
visible: "safe-value",
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
const functionResponse = (params.contents[1] as GoogleTestContentTurn).parts[0]
|
||||
.functionResponse as { response: { output: string } };
|
||||
|
||||
expect(functionResponse.response.output).toContain('"visible":"safe-value"');
|
||||
expect(functionResponse.response.output).toContain('"code":"ERR_VISIBLE_GOOGLE_CODE"');
|
||||
expect(functionResponse.response.output).toContain('"code":"ERR_VISIBLE_PROVIDER_GOOGLE_CODE"');
|
||||
for (const leakedValue of [
|
||||
"leaked-private-key-value-12345",
|
||||
"leaked-private-key-snake-12345",
|
||||
"leaked-generic-key-value-12345",
|
||||
"leaked-key-material-value-12345",
|
||||
"leaked-jwt-value-1234567890",
|
||||
"leaked-session-value-123456",
|
||||
"code-value-1234567890",
|
||||
"OPAQUEGOOGLECODE1234567890",
|
||||
"leaked-signature-value-12345",
|
||||
"leaked-cookie-value-123456",
|
||||
"leaked-set-cookie-value-12345",
|
||||
"leaked-payment-credential-12345",
|
||||
"41111111111111112222",
|
||||
]) {
|
||||
expect(functionResponse.response.output).not.toContain(leakedValue);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Google media-only tool results on media placeholders", () => {
|
||||
const params = buildGoogleGenerativeAiParams(buildGeminiModel(), {
|
||||
messages: [
|
||||
googleToolCallAssistantTurn(),
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [{ type: "audio", mimeType: "audio/wav", data: "wav-bytes" }],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
expect(params.contents[1]).toMatchObject({
|
||||
parts: [{ functionResponse: { response: { output: "(see attached audio)" } } }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["image first", ["screenshot", "weather"]],
|
||||
["image last", ["weather", "screenshot"]],
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
coerceTransportToolCallArguments,
|
||||
createEmptyTransportUsage,
|
||||
createWritableTransportEventStream,
|
||||
describeToolResultMediaPlaceholder,
|
||||
extractToolResultText,
|
||||
failTransportStream,
|
||||
finalizeTransportStream,
|
||||
mergeTransportHeaders,
|
||||
@@ -647,24 +649,17 @@ function convertGoogleMessages(model: GoogleTransportModel, context: Context) {
|
||||
}
|
||||
|
||||
if (msg.role === "toolResult") {
|
||||
const textResult = msg.content
|
||||
.filter(
|
||||
(item): item is Extract<(typeof msg.content)[number], { type: "text" }> =>
|
||||
item.type === "text",
|
||||
)
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
const textResult = extractToolResultText(msg.content);
|
||||
const imageContent = model.input.includes("image")
|
||||
? msg.content.filter(
|
||||
(item): item is Extract<(typeof msg.content)[number], { type: "image" }> =>
|
||||
item.type === "image",
|
||||
)
|
||||
: [];
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content);
|
||||
const responseValue = textResult
|
||||
? sanitizeTransportPayloadText(textResult)
|
||||
: imageContent.length > 0
|
||||
? "(see attached image)"
|
||||
: "";
|
||||
: (mediaPlaceholder ?? "");
|
||||
const imageParts = imageContent.map((imageBlock) => ({
|
||||
inlineData: {
|
||||
mimeType: imageBlock.mimeType,
|
||||
|
||||
@@ -625,4 +625,31 @@ describe("xai stream wrappers", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses audio fallback text for audio-only tool outputs", () => {
|
||||
const payload: Record<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_audio",
|
||||
output: [
|
||||
{
|
||||
type: "input_audio",
|
||||
mimeType: "audio/wav",
|
||||
data: "QUJDRA==",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
runXaiToolPayloadWrapper({ payload, input: ["text"] });
|
||||
|
||||
expect(payload.input).toEqual([
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_audio",
|
||||
output: "(see attached audio)",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,47 @@ function isReplayableInputImagePart(
|
||||
);
|
||||
}
|
||||
|
||||
function describeXaiFunctionOutputMediaPlaceholder(
|
||||
parts: Array<Record<string, unknown>>,
|
||||
): string | undefined {
|
||||
let hasImage = false;
|
||||
let hasAudio = false;
|
||||
let hasOtherMedia = false;
|
||||
|
||||
for (const part of parts) {
|
||||
const type = typeof part.type === "string" ? part.type : "";
|
||||
const mimeType =
|
||||
typeof part.mimeType === "string"
|
||||
? part.mimeType
|
||||
: typeof part.mime_type === "string"
|
||||
? part.mime_type
|
||||
: typeof part.mediaType === "string"
|
||||
? part.mediaType
|
||||
: typeof part.contentType === "string"
|
||||
? part.contentType
|
||||
: "";
|
||||
const normalizedMime = mimeType.toLowerCase();
|
||||
if (type.includes("image") || normalizedMime.startsWith("image/")) {
|
||||
hasImage = true;
|
||||
} else if (type.includes("audio") || normalizedMime.startsWith("audio/")) {
|
||||
hasAudio = true;
|
||||
} else if (type !== "input_text") {
|
||||
hasOtherMedia = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ((hasImage && hasAudio) || hasOtherMedia) {
|
||||
return "(see attached media)";
|
||||
}
|
||||
if (hasAudio) {
|
||||
return "(see attached audio)";
|
||||
}
|
||||
if (hasImage) {
|
||||
return "(see attached image)";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeXaiResponsesFunctionCallOutput(
|
||||
item: unknown,
|
||||
includeImages: boolean,
|
||||
@@ -143,11 +184,12 @@ function normalizeXaiResponsesFunctionCallOutput(
|
||||
)
|
||||
: [];
|
||||
const hadNonTextParts = outputParts.some((part) => part.type !== "input_text");
|
||||
const mediaPlaceholder = describeXaiFunctionOutputMediaPlaceholder(outputParts);
|
||||
|
||||
return {
|
||||
normalizedItem: {
|
||||
...itemObj,
|
||||
output: textOutput || (hadNonTextParts ? "(see attached image)" : ""),
|
||||
output: textOutput || mediaPlaceholder || (hadNonTextParts ? "(see attached media)" : ""),
|
||||
},
|
||||
imageParts,
|
||||
};
|
||||
|
||||
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10402),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5220),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10404),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5222),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
3261,
|
||||
|
||||
@@ -141,6 +141,7 @@ function makeAnthropicTransportModel(
|
||||
reasoning?: boolean;
|
||||
params?: Record<string, unknown>;
|
||||
maxTokens?: number;
|
||||
input?: AnthropicMessagesModel["input"];
|
||||
thinkingLevelMap?: AnthropicMessagesModel["thinkingLevelMap"];
|
||||
headers?: Record<string, string>;
|
||||
authHeader?: boolean;
|
||||
@@ -156,7 +157,7 @@ function makeAnthropicTransportModel(
|
||||
baseUrl: params.baseUrl ?? "https://api.anthropic.com",
|
||||
reasoning: params.reasoning ?? true,
|
||||
...(params.params ? { params: params.params } : {}),
|
||||
input: ["text"],
|
||||
input: params.input ?? ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: params.maxTokens ?? 8192,
|
||||
@@ -2554,6 +2555,122 @@ describe("anthropic transport stream", () => {
|
||||
expect(toolResult.is_error).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes structured non-image blocks in tool results as JSON text", async () => {
|
||||
await runTransportStream(
|
||||
makeAnthropicTransportModel({ id: "claude-sonnet-4-6" }),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
content: [{ type: "toolCall", id: "tool_1", name: "fetch", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool_1",
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "https://example.com/data.json",
|
||||
mimeType: "application/json",
|
||||
text: '{"key":"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
} as AnthropicStreamContext,
|
||||
{
|
||||
apiKey: "sk-ant-api",
|
||||
} as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
const userMessage = findRecord(
|
||||
latestAnthropicRequest().payload.messages,
|
||||
(record) => record.role === "user",
|
||||
);
|
||||
const toolResult = findRecord(
|
||||
userMessage.content,
|
||||
(record) => record.type === "tool_result" && record.tool_use_id === "tool_1",
|
||||
);
|
||||
// No images → returns sanitized text string, not array
|
||||
expect(typeof toolResult.content).toBe("string");
|
||||
expect(toolResult.content).toContain('"type":"resource"');
|
||||
expect(toolResult.content).toContain('{\\"key\\":\\"***\\"}');
|
||||
expect(toolResult.is_error).toBe(false);
|
||||
});
|
||||
|
||||
it("includes serialized structured blocks alongside images in tool results", async () => {
|
||||
const imageData = Buffer.from("image").toString("base64");
|
||||
|
||||
await runTransportStream(
|
||||
makeAnthropicTransportModel({ id: "claude-sonnet-4-6", input: ["text", "image"] }),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
content: [{ type: "toolCall", id: "tool_1", name: "screenshot", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool_1",
|
||||
content: [
|
||||
{ type: "text", text: "before image" },
|
||||
{ type: "image", data: imageData, mimeType: "image/png" },
|
||||
{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "https://example.com/data.json",
|
||||
mimeType: "application/json",
|
||||
text: '{"key":"value"}',
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "after image" },
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
} as AnthropicStreamContext,
|
||||
{
|
||||
apiKey: "sk-ant-api",
|
||||
} as AnthropicStreamOptions,
|
||||
);
|
||||
|
||||
const userMessage = findRecord(
|
||||
latestAnthropicRequest().payload.messages,
|
||||
(record) => record.role === "user",
|
||||
);
|
||||
const toolResult = findRecord(
|
||||
userMessage.content,
|
||||
(record) => record.type === "tool_result" && record.tool_use_id === "tool_1",
|
||||
);
|
||||
expect(toolResult.content).toEqual([
|
||||
{ type: "text", text: "before image" },
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: imageData,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: expect.stringContaining('{"type":"resource"') },
|
||||
{ type: "text", text: "after image" },
|
||||
]);
|
||||
expect(toolResult.is_error).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels stalled SSE body reads when the abort signal fires mid-stream", async () => {
|
||||
const controller = new AbortController();
|
||||
const abortReason = new Error("anthropic test abort");
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
findActiveAnthropicToolTurnAssistantIndex,
|
||||
} from "../llm/providers/anthropic-thinking-replay.js";
|
||||
import type { AnthropicOptions, AnthropicThinkingDisplay } from "../llm/providers/anthropic.js";
|
||||
import {
|
||||
describeToolResultMediaPlaceholder,
|
||||
extractToolResultBlockText,
|
||||
extractToolResultText,
|
||||
} from "../llm/providers/tool-result-text.js";
|
||||
import type {
|
||||
AssistantMessageDiagnostic,
|
||||
Context,
|
||||
@@ -296,16 +301,17 @@ function toClaudeCodeName(name: string): string {
|
||||
return CLAUDE_CODE_TOOL_LOOKUP.get(normalizeLowercaseStringOrEmpty(name)) ?? name;
|
||||
}
|
||||
|
||||
function convertContentBlocks(
|
||||
content: Array<
|
||||
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
|
||||
>,
|
||||
) {
|
||||
const hasImages = content.some((item) => item.type === "image");
|
||||
if (!hasImages) {
|
||||
return sanitizeNonEmptyTransportPayloadText(
|
||||
content.map((item) => ("text" in item ? item.text : "")).join("\n"),
|
||||
function convertContentBlocks(content: readonly unknown[]) {
|
||||
const text = extractToolResultText(content);
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(content);
|
||||
const hasImages =
|
||||
Array.isArray(content) &&
|
||||
content.some(
|
||||
(item) =>
|
||||
item && typeof item === "object" && (item as Record<string, unknown>).type === "image",
|
||||
);
|
||||
if (!hasImages) {
|
||||
return sanitizeNonEmptyTransportPayloadText(text, mediaPlaceholder ?? "(no output)");
|
||||
}
|
||||
const blocks: Array<
|
||||
| { type: "text"; text: string }
|
||||
@@ -315,26 +321,30 @@ function convertContentBlocks(
|
||||
}
|
||||
> = [];
|
||||
let hasTextBlock = false;
|
||||
for (const block of content) {
|
||||
if (block.type === "text") {
|
||||
const text = sanitizeTransportPayloadText(block.text);
|
||||
if (text.trim().length > 0) {
|
||||
blocks.push({ type: "text", text });
|
||||
hasTextBlock = true;
|
||||
}
|
||||
} else {
|
||||
blocks.push({
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: block.mimeType,
|
||||
data: block.data,
|
||||
},
|
||||
});
|
||||
for (const block of Array.isArray(content) ? content : []) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
const blockText = extractToolResultBlockText(block);
|
||||
if (blockText) {
|
||||
blocks.push({ type: "text", text: sanitizeTransportPayloadText(blockText) });
|
||||
hasTextBlock = true;
|
||||
}
|
||||
if (record.type !== "image") {
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: typeof record.mimeType === "string" ? record.mimeType : "image/png",
|
||||
data: typeof record.data === "string" ? record.data : "",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!hasTextBlock) {
|
||||
return [{ type: "text", text: "(see attached image)" }, ...blocks];
|
||||
blocks.unshift({ type: "text", text: mediaPlaceholder ?? "(see attached image)" });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,54 @@ describe("extractToolErrorMessage", () => {
|
||||
).toBe("INVALID_REQUEST");
|
||||
});
|
||||
|
||||
it("preserves structured diagnostic tool error codes through sanitization", () => {
|
||||
const sanitized = sanitizeToolResult({
|
||||
details: {
|
||||
status: "failed",
|
||||
error: {
|
||||
code: "SYSTEM_RUN_DENIED",
|
||||
message: "approval required",
|
||||
},
|
||||
},
|
||||
}) as { details: { error: { code: string; message: string } } };
|
||||
|
||||
expect(sanitized.details.error.code).toBe("SYSTEM_RUN_DENIED");
|
||||
expect(extractToolErrorCode(sanitized)).toBe("SYSTEM_RUN_DENIED");
|
||||
});
|
||||
|
||||
it("preserves structured invalid-request tool error codes through sanitization", () => {
|
||||
const sanitized = sanitizeToolResult({
|
||||
details: {
|
||||
status: "failed",
|
||||
nodeError: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "approval expired",
|
||||
},
|
||||
},
|
||||
}) as { details: { nodeError: { code: string; message: string } } };
|
||||
|
||||
expect(sanitized.details.nodeError.code).toBe("INVALID_REQUEST");
|
||||
expect(extractToolErrorCode(sanitized)).toBe("INVALID_REQUEST");
|
||||
});
|
||||
|
||||
it("preserves direct structured tool error codes through sanitization", () => {
|
||||
const detailsCode = sanitizeToolResult({
|
||||
details: {
|
||||
status: "failed",
|
||||
code: "output_limit_exceeded",
|
||||
},
|
||||
}) as { details: { code: string } };
|
||||
const rootCode = sanitizeToolResult({
|
||||
status: "failed",
|
||||
code: "output_limit_exceeded",
|
||||
}) as { code: string };
|
||||
|
||||
expect(detailsCode.details.code).toBe("output_limit_exceeded");
|
||||
expect(extractToolErrorCode(detailsCode)).toBe("output_limit_exceeded");
|
||||
expect(rootCode.code).toBe("output_limit_exceeded");
|
||||
expect(extractToolErrorCode(rootCode)).toBe("output_limit_exceeded");
|
||||
});
|
||||
|
||||
it("does not extract error codes from prose-only tool output", () => {
|
||||
expect(
|
||||
extractToolErrorCode({
|
||||
|
||||
@@ -14,7 +14,11 @@ import type { ChannelMessageActionName } from "../channels/plugins/types.public.
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
||||
import { normalizeInteractiveReply, normalizeMessagePresentation } from "../interactive/payload.js";
|
||||
import { redactSensitiveFieldValue, redactToolPayloadText } from "../logging/redact.js";
|
||||
import {
|
||||
redactSecrets,
|
||||
redactSensitiveFieldValue,
|
||||
redactToolPayloadText,
|
||||
} from "../logging/redact.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { collectTextContentBlocks } from "./content-blocks.js";
|
||||
import { isMessagingToolTargetEvidenceAction } from "./embedded-agent-messaging.js";
|
||||
@@ -234,7 +238,7 @@ export function sanitizeToolResult(result: unknown): unknown {
|
||||
return redactToolPayloadText(result);
|
||||
}
|
||||
if (Array.isArray(result)) {
|
||||
return redactStringsDeep(result);
|
||||
return redactSecrets(result);
|
||||
}
|
||||
if (!result || typeof result !== "object") {
|
||||
return result;
|
||||
@@ -262,7 +266,7 @@ export function sanitizeToolResult(result: unknown): unknown {
|
||||
}
|
||||
// Deep-redact the entire result so any top-level or nested string is
|
||||
// protected, not just `details` and text content blocks.
|
||||
const baseline = redactStringsDeep(preCleaned) as Record<string, unknown>;
|
||||
const baseline = redactSecrets(preCleaned);
|
||||
const out: Record<string, unknown> = { ...baseline };
|
||||
const content = Array.isArray(baseline.content) ? baseline.content : null;
|
||||
if (content) {
|
||||
|
||||
@@ -5237,6 +5237,74 @@ describe("openai transport stream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("serializes structured tool result content (e.g. json blocks) into Responses function_call_output text", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-responses">,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
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: 1,
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: { query: "price" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_lookup",
|
||||
toolName: "lookup",
|
||||
content: [{ type: "json", payload: { price: 42, currency: "USD" } }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
{ role: "user", content: "continue", timestamp: 3 },
|
||||
],
|
||||
tools: [],
|
||||
} as never,
|
||||
undefined,
|
||||
) as {
|
||||
input?: Array<{ type?: string; call_id?: string; output?: unknown }>;
|
||||
};
|
||||
|
||||
const output = params.input?.find((item) => item.type === "function_call_output");
|
||||
expect(output).toBeDefined();
|
||||
expect(output?.call_id).toBe("call_lookup");
|
||||
const outputText = output?.output as string;
|
||||
expect(typeof outputText).toBe("string");
|
||||
expect(outputText).toContain("price");
|
||||
expect(outputText).toContain("42");
|
||||
expect(outputText).not.toBe("(see attached image)");
|
||||
});
|
||||
|
||||
it("omits distinct overlong Copilot Responses replay item ids when store is disabled", () => {
|
||||
const sharedToolItemPrefix = "iVec" + "A".repeat(160);
|
||||
const firstToolCallId = `call_first|${sharedToolItemPrefix}Aa`;
|
||||
|
||||
@@ -27,6 +27,10 @@ import { resolveAzureDeploymentNameFromMap } from "../llm/providers/azure-deploy
|
||||
import { convertMessages } from "../llm/providers/openai-completions.js";
|
||||
import { clampOpenAIPromptCacheKey } from "../llm/providers/openai-prompt-cache.js";
|
||||
import { mapOpenAIStopReason } from "../llm/providers/openai-stop-reason.js";
|
||||
import {
|
||||
describeToolResultMediaPlaceholder,
|
||||
extractToolResultText,
|
||||
} from "../llm/providers/tool-result-text.js";
|
||||
import type { Api, Context, Model } from "../llm/types.js";
|
||||
import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js";
|
||||
import { parseStreamingJson } from "../llm/utils/json-parse.js";
|
||||
@@ -1274,12 +1278,10 @@ function convertResponsesMessages(
|
||||
messages.push(...output);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
const textResult = msg.content
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
const textResult = extractToolResultText(msg.content);
|
||||
const sanitizedTextResult = sanitizeTransportPayloadText(textResult);
|
||||
const hasText = sanitizedTextResult.trim().length > 0;
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content);
|
||||
const hasImages = msg.content.some((item) => item.type === "image");
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
messages.push({
|
||||
@@ -1288,7 +1290,11 @@ function convertResponsesMessages(
|
||||
output:
|
||||
hasImages && model.input.includes("image")
|
||||
? ([
|
||||
...(hasText ? [{ type: "input_text", text: sanitizedTextResult }] : []),
|
||||
...(hasText
|
||||
? [{ type: "input_text", text: sanitizedTextResult }]
|
||||
: mediaPlaceholder === "(see attached media)"
|
||||
? [{ type: "input_text", text: mediaPlaceholder }]
|
||||
: []),
|
||||
...msg.content
|
||||
.filter((item) => item.type === "image")
|
||||
.map((item) => ({
|
||||
@@ -1297,10 +1303,7 @@ function convertResponsesMessages(
|
||||
image_url: `data:${item.mimeType};base64,${item.data}`,
|
||||
})),
|
||||
] as ResponseFunctionCallOutputItemList)
|
||||
: sanitizeNonEmptyTransportPayloadText(
|
||||
textResult,
|
||||
hasImages ? "(see attached image)" : "(no output)",
|
||||
),
|
||||
: sanitizeNonEmptyTransportPayloadText(textResult, mediaPlaceholder ?? "(no output)"),
|
||||
});
|
||||
}
|
||||
msgIndex += 1;
|
||||
|
||||
@@ -384,6 +384,82 @@ describe("Anthropic provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves mixed text and image tool-result order", async () => {
|
||||
let capturedPayload: unknown;
|
||||
const imageData = Buffer.from("image").toString("base64");
|
||||
const stream = streamAnthropic(
|
||||
makeAnthropicModel({ input: ["text", "image"] }),
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
model: "claude-sonnet-4-6",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
content: [{ type: "toolCall", id: "call_1", name: "lookup", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "lookup",
|
||||
content: [
|
||||
{ type: "text", text: "before image" },
|
||||
{ type: "image", data: imageData, mimeType: "image/png" },
|
||||
{
|
||||
type: "resource",
|
||||
resource: { uri: "https://example.com/data.json", text: '{"key":"value"}' },
|
||||
},
|
||||
{ type: "text", text: "after image" },
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
apiKey: "sk-ant-provider",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
throw new Error("stop before network");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await stream.result();
|
||||
|
||||
const payload = capturedPayload as {
|
||||
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
||||
};
|
||||
const userMessage = payload.messages.find((message) => message.role === "user");
|
||||
const toolResult = userMessage?.content.find((entry) => entry.type === "tool_result") as {
|
||||
content: unknown[];
|
||||
};
|
||||
|
||||
expect(toolResult.content).toEqual([
|
||||
{ type: "text", text: "before image" },
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: imageData,
|
||||
},
|
||||
},
|
||||
{ type: "text", text: expect.stringContaining('{"type":"resource"') },
|
||||
{ type: "text", text: "after image" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["anthropic", "sk-ant-provider"],
|
||||
["anthropic-vertex", "vertex-token"],
|
||||
|
||||
@@ -43,7 +43,6 @@ import type {
|
||||
AssistantMessageEvent,
|
||||
CacheRetention,
|
||||
Context,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
@@ -69,6 +68,11 @@ import { resolveCacheRetention } from "./cache-retention.js";
|
||||
import { resolveCloudflareBaseUrl } from "./cloudflare.js";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js";
|
||||
import { adjustMaxTokensForThinking, buildBaseOptions } from "./simple-options.js";
|
||||
import {
|
||||
describeToolResultMediaPlaceholder,
|
||||
extractToolResultBlockText,
|
||||
extractToolResultText,
|
||||
} from "./tool-result-text.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
|
||||
const ANTHROPIC_CACHE_CONTROL_LIMIT = 4;
|
||||
@@ -123,7 +127,7 @@ const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase())
|
||||
/**
|
||||
* Convert content blocks to Anthropic API format
|
||||
*/
|
||||
function convertContentBlocks(content: (TextContent | ImageContent)[]):
|
||||
function convertContentBlocks(content: readonly unknown[]):
|
||||
| string
|
||||
| Array<
|
||||
| { type: "text"; text: string }
|
||||
@@ -136,38 +140,62 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]):
|
||||
};
|
||||
}
|
||||
> {
|
||||
// If only text blocks, return as concatenated string for simplicity
|
||||
const hasImages = content.some((c) => c.type === "image");
|
||||
const text = extractToolResultText(content);
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(content);
|
||||
const hasImages =
|
||||
Array.isArray(content) &&
|
||||
content.some(
|
||||
(item) =>
|
||||
item && typeof item === "object" && (item as Record<string, unknown>).type === "image",
|
||||
);
|
||||
|
||||
if (!hasImages) {
|
||||
return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n"));
|
||||
const sanitized = sanitizeSurrogates(text);
|
||||
return sanitized.trim().length > 0 ? sanitized : (mediaPlaceholder ?? "");
|
||||
}
|
||||
|
||||
// If we have images, convert to content block array
|
||||
const blocks = content.map((block) => {
|
||||
if (block.type === "text") {
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: sanitizeSurrogates(block.text),
|
||||
};
|
||||
const blocks: Array<
|
||||
| { type: "text"; text: string }
|
||||
| {
|
||||
type: "image";
|
||||
source: {
|
||||
type: "base64";
|
||||
media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp";
|
||||
data: string;
|
||||
};
|
||||
}
|
||||
> = [];
|
||||
let hasTextBlock = false;
|
||||
|
||||
for (const block of Array.isArray(content) ? content : []) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
const record = block as Record<string, unknown>;
|
||||
const blockText = extractToolResultBlockText(block);
|
||||
if (blockText) {
|
||||
blocks.push({ type: "text" as const, text: sanitizeSurrogates(blockText) });
|
||||
hasTextBlock = true;
|
||||
}
|
||||
if (record.type !== "image") {
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: block.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
|
||||
data: block.data,
|
||||
media_type: (typeof record.mimeType === "string" ? record.mimeType : "image/jpeg") as
|
||||
| "image/jpeg"
|
||||
| "image/png"
|
||||
| "image/gif"
|
||||
| "image/webp",
|
||||
data: typeof record.data === "string" ? record.data : "",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// If only images (no text), add placeholder text block
|
||||
const hasText = blocks.some((b) => b.type === "text");
|
||||
if (!hasText) {
|
||||
blocks.unshift({
|
||||
type: "text" as const,
|
||||
text: "(see attached image)",
|
||||
});
|
||||
}
|
||||
if (!hasTextBlock) {
|
||||
blocks.unshift({ type: "text" as const, text: mediaPlaceholder ?? "(see attached image)" });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -375,4 +375,29 @@ describe("google-shared convertMessages", () => {
|
||||
expect(asRecord(toolCall.functionCall).id).toBeUndefined();
|
||||
expect(asRecord(toolResponse.functionResponse).id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializes structured tool results into function responses", () => {
|
||||
const model = makeModel("gemini-1.5-pro");
|
||||
const context = {
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "session_status",
|
||||
content: [{ type: "json", payload: { sessionKey: "current", status: "ok" } }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as Context;
|
||||
const contents = convertMessagesForTest(model, context);
|
||||
const toolResponsePart = contents[0]?.parts?.find(
|
||||
(part) => typeof part === "object" && part !== null && "functionResponse" in part,
|
||||
);
|
||||
expect(toolResponsePart).toBeDefined();
|
||||
const toolResponse = requireRecordProperty(asRecord(toolResponsePart), "functionResponse");
|
||||
expect(asRecord(toolResponse.response).output).toBe(
|
||||
'{"type":"json","payload":{"sessionKey":"current","status":"ok"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
} from "../types.js";
|
||||
import type { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
|
||||
export type GoogleApiType = "google-generative-ai" | "google-vertex";
|
||||
@@ -278,14 +279,14 @@ export function convertMessages<T extends GoogleApiType>(
|
||||
});
|
||||
} else if (msg.role === "toolResult") {
|
||||
// Extract text and image content
|
||||
const textContent = msg.content.filter((c): c is TextContent => c.type === "text");
|
||||
const textResult = textContent.map((c) => c.text).join("\n");
|
||||
const textResult = extractToolResultText(msg.content);
|
||||
const imageContent = model.input.includes("image")
|
||||
? msg.content.filter((c): c is ImageContent => c.type === "image")
|
||||
: [];
|
||||
|
||||
const hasText = textResult.length > 0;
|
||||
const hasImages = imageContent.length > 0;
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content);
|
||||
|
||||
// Gemini 3+ models support multimodal function responses with images nested inside
|
||||
// functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
|
||||
@@ -293,11 +294,7 @@ export function convertMessages<T extends GoogleApiType>(
|
||||
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
|
||||
|
||||
// Use "output" key for success, "error" key for errors as per SDK documentation
|
||||
const responseValue = hasText
|
||||
? sanitizeSurrogates(textResult)
|
||||
: hasImages
|
||||
? "(see attached image)"
|
||||
: "";
|
||||
const responseValue = hasText ? sanitizeSurrogates(textResult) : (mediaPlaceholder ?? "");
|
||||
|
||||
const imageParts: Part[] = imageContent.map((imageBlock) => ({
|
||||
inlineData: {
|
||||
|
||||
@@ -245,4 +245,108 @@ describe("Mistral provider", () => {
|
||||
expect(systemMessage?.content).toBe("Stable\nDynamic");
|
||||
expect(JSON.stringify(payload)).not.toContain("OPENCLAW_CACHE_BOUNDARY");
|
||||
});
|
||||
|
||||
it("serializes structured non-image blocks in tool results as JSON text", async () => {
|
||||
const testContext = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "hello",
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "mistral",
|
||||
api: "mistral-conversations",
|
||||
model: "mistral-large-latest",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
content: [{ type: "toolCall", id: "tool_1", name: "fetch", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool_1",
|
||||
content: [
|
||||
{
|
||||
type: "resource",
|
||||
resource: {
|
||||
uri: "https://example.com/data.json",
|
||||
mimeType: "application/json",
|
||||
text: '{"key":"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as Context;
|
||||
|
||||
const stream = streamMistral(makeMistralModel(), testContext, {
|
||||
apiKey: "sk-mistral-provider",
|
||||
});
|
||||
await stream.result();
|
||||
|
||||
const payload = mistralMockState.payloads[0] as {
|
||||
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
const toolMessage = payload.messages.find((message) => message.role === "tool");
|
||||
expect(toolMessage).toBeDefined();
|
||||
const toolContent = Array.isArray(toolMessage!.content) ? toolMessage!.content : [];
|
||||
const textBlock = toolContent.find((block) => block.type === "text");
|
||||
expect(textBlock?.text).toEqual(expect.stringContaining('{"type":"resource"'));
|
||||
expect(textBlock?.text).toContain('{\\"key\\":\\"value\\"}');
|
||||
});
|
||||
|
||||
it("serializes structured-only tool results instead of empty fallback", async () => {
|
||||
const testContext = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "hello",
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
provider: "mistral",
|
||||
api: "mistral-conversations",
|
||||
model: "mistral-large-latest",
|
||||
stopReason: "toolUse",
|
||||
timestamp: 0,
|
||||
content: [{ type: "toolCall", id: "tool_1", name: "get_file", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool_1",
|
||||
content: [
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "https://example.com/file.txt",
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
size: 100,
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as Context;
|
||||
|
||||
const stream = streamMistral(makeMistralModel(), testContext, {
|
||||
apiKey: "sk-mistral-provider",
|
||||
});
|
||||
await stream.result();
|
||||
|
||||
const payload = mistralMockState.payloads[0] as {
|
||||
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string }> }>;
|
||||
};
|
||||
const toolMessage = payload.messages.find((message) => message.role === "tool");
|
||||
expect(toolMessage).toBeDefined();
|
||||
const toolContent = Array.isArray(toolMessage!.content) ? toolMessage!.content : [];
|
||||
const textBlock = toolContent.find((block) => block.type === "text");
|
||||
// Structured blocks should provide the output, not an empty fallback
|
||||
expect(textBlock?.text).toEqual(expect.stringContaining('{"type":"resource_link"'));
|
||||
expect(textBlock?.text).not.toContain("(no tool output)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { shortHash } from "../utils/hash.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { buildBaseOptions } from "./simple-options.js";
|
||||
import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
|
||||
const MISTRAL_TOOL_CALL_ID_LENGTH = 9;
|
||||
@@ -691,12 +692,16 @@ function toChatMessages(
|
||||
}
|
||||
|
||||
const toolContent: ContentChunk[] = [];
|
||||
const textResult = msg.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : ""))
|
||||
.join("\n");
|
||||
const textResult = extractToolResultText(msg.content);
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content);
|
||||
const hasImages = msg.content.some((part) => part.type === "image");
|
||||
const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError);
|
||||
const toolText = buildToolResultText(
|
||||
textResult,
|
||||
mediaPlaceholder,
|
||||
hasImages,
|
||||
supportsImages,
|
||||
msg.isError,
|
||||
);
|
||||
toolContent.push({ type: "text", text: toolText });
|
||||
for (const part of msg.content) {
|
||||
if (!supportsImages) {
|
||||
@@ -723,6 +728,7 @@ function toChatMessages(
|
||||
|
||||
function buildToolResultText(
|
||||
text: string,
|
||||
mediaPlaceholder: string | undefined,
|
||||
hasImages: boolean,
|
||||
supportsImages: boolean,
|
||||
isError: boolean,
|
||||
@@ -736,13 +742,15 @@ function buildToolResultText(
|
||||
return `${errorPrefix}${trimmed}${imageSuffix}`;
|
||||
}
|
||||
|
||||
if (hasImages) {
|
||||
if (supportsImages) {
|
||||
return isError ? "[tool error] (see attached image)" : "(see attached image)";
|
||||
if (mediaPlaceholder) {
|
||||
if (!hasImages || supportsImages) {
|
||||
return `${errorPrefix}${mediaPlaceholder}`;
|
||||
}
|
||||
return isError
|
||||
? "[tool error] (image omitted: model does not support images)"
|
||||
: "(image omitted: model does not support images)";
|
||||
const omitted =
|
||||
mediaPlaceholder === "(see attached media)"
|
||||
? "(media omitted: model does not support images)"
|
||||
: "(image omitted: model does not support images)";
|
||||
return `${errorPrefix}${omitted}`;
|
||||
}
|
||||
|
||||
return isError ? "[tool error] (no tool output)" : "(no tool output)";
|
||||
|
||||
@@ -1198,4 +1198,43 @@ describe("openai-completions stop-reason tool-call guard", () => {
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(result.content.filter((b) => b.type === "toolCall")).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("serializes structured tool results as tool text", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const stream = streamOpenAICompletions(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "session_status",
|
||||
content: [{ type: "json", payload: { sessionKey: "current", status: "ok" } }],
|
||||
isError: false,
|
||||
timestamp: 0,
|
||||
},
|
||||
],
|
||||
} as unknown as Context,
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
onPayload(payload) {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
throw new Error("stop before network");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(capturedPayload?.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
tool_call_id: "call_1",
|
||||
content: expect.stringContaining('"type":"json"'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copi
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
||||
import { mapOpenAIStopReason } from "./openai-stop-reason.js";
|
||||
import { buildBaseOptions } from "./simple-options.js";
|
||||
import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
|
||||
/**
|
||||
@@ -92,7 +93,6 @@ function isImageContentBlock(block: { type: string }): block is ImageContent {
|
||||
}
|
||||
|
||||
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
|
||||
const IMAGE_TOOL_RESULT_TEXT = "(see attached image)";
|
||||
|
||||
function sanitizeToolResultText(text: string, fallback: string): string {
|
||||
const sanitized = sanitizeSurrogates(text);
|
||||
@@ -1138,16 +1138,14 @@ export function convertMessages(
|
||||
const toolMsg = transformedMessages[j] as ToolResultMessage;
|
||||
|
||||
// Extract text and image content
|
||||
const textResult = toolMsg.content
|
||||
.filter(isTextContentBlock)
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
const textResult = extractToolResultText(toolMsg.content);
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(toolMsg.content);
|
||||
const hasImages = toolMsg.content.some((c) => c.type === "image");
|
||||
|
||||
// Always send tool result with text (or placeholder if only images)
|
||||
const content = sanitizeToolResultText(
|
||||
textResult,
|
||||
hasImages ? IMAGE_TOOL_RESULT_TEXT : EMPTY_TOOL_RESULT_TEXT,
|
||||
mediaPlaceholder ?? EMPTY_TOOL_RESULT_TEXT,
|
||||
);
|
||||
// Some providers require the 'name' field in tool results
|
||||
const toolResultMsg: ChatCompletionToolMessageParam = {
|
||||
|
||||
@@ -70,6 +70,8 @@ const gpt56SolModel = {
|
||||
thinkingLevelMap: { off: null, xhigh: "xhigh", max: "max" },
|
||||
} satisfies Model<"openai-responses">;
|
||||
|
||||
const testAllowedToolCallProviders = new Set(["openai", "openai-codex", "opencode"]);
|
||||
|
||||
function createAssistantOutput(): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
@@ -284,7 +286,7 @@ describe("Responses reasoning effort", () => {
|
||||
});
|
||||
|
||||
describe("convertResponsesMessages", () => {
|
||||
const allowedToolCallProviders = new Set(["openai", "openai-codex", "opencode"]);
|
||||
const allowedToolCallProviders = testAllowedToolCallProviders;
|
||||
|
||||
it("adds explicit message item types for system and user input items", () => {
|
||||
const input = convertResponsesMessages(
|
||||
@@ -620,6 +622,53 @@ describe("convertResponsesMessages", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses audio placeholder for audio-only tool results instead of image or no-output text", () => {
|
||||
const input = convertResponsesMessages(
|
||||
nativeOpenAIModel,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: nativeOpenAIModel.api,
|
||||
provider: nativeOpenAIModel.provider,
|
||||
model: nativeOpenAIModel.id,
|
||||
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: 1,
|
||||
content: [{ type: "toolCall", id: "call_audio", name: "audio", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_audio",
|
||||
toolName: "audio",
|
||||
content: [{ type: "audio", mimeType: "audio/mpeg", data: "YXVkaW8=" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
} as unknown as Context,
|
||||
allowedToolCallProviders,
|
||||
{ includeSystemPrompt: false },
|
||||
) as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
const functionOutput = input.find((item) => item.type === "function_call_output");
|
||||
expect(functionOutput).toMatchObject({
|
||||
type: "function_call_output",
|
||||
call_id: "call_audio",
|
||||
output: "(see attached audio)",
|
||||
});
|
||||
expect(functionOutput?.output).not.toBe("(see attached image)");
|
||||
expect(functionOutput?.output).not.toBe("(no output)");
|
||||
});
|
||||
|
||||
it("keeps encrypted reasoning replay item ids when requested", () => {
|
||||
const input = convertResponsesMessages(
|
||||
nativeOpenAIModel,
|
||||
@@ -666,6 +715,37 @@ describe("convertResponsesMessages", () => {
|
||||
summary: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes structured tool results as text instead of image placeholders", () => {
|
||||
const input = convertResponsesMessages(
|
||||
nativeOpenAIModel,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_structured",
|
||||
toolName: "session_status",
|
||||
content: [
|
||||
{
|
||||
type: "json",
|
||||
payload: { sessionKey: "current", model: "openai/gpt-5.4", status: "ok" },
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
},
|
||||
],
|
||||
} as unknown as Context,
|
||||
testAllowedToolCallProviders,
|
||||
{ includeSystemPrompt: false, replayResponsesItemIds: false },
|
||||
) as unknown as Array<Record<string, unknown>>;
|
||||
expect(input).toContainEqual({
|
||||
type: "function_call_output",
|
||||
call_id: "call_structured",
|
||||
output: expect.stringContaining('"type":"json"'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("processResponsesStream", () => {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { headersToRecord } from "../utils/headers.js";
|
||||
import { parseStreamingJson } from "../utils/json-parse.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { convertResponsesToolPayload, convertResponsesTools } from "./openai-responses-tools.js";
|
||||
import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js";
|
||||
import { transformMessages } from "./transform-messages.js";
|
||||
|
||||
// =============================================================================
|
||||
@@ -56,7 +57,6 @@ import { transformMessages } from "./transform-messages.js";
|
||||
// =============================================================================
|
||||
|
||||
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
|
||||
const IMAGE_TOOL_RESULT_TEXT = "(see attached image)";
|
||||
|
||||
function sanitizeToolResultText(text: string, fallback: string): string {
|
||||
const sanitized = sanitizeSurrogates(text);
|
||||
@@ -401,12 +401,10 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
}
|
||||
messages.push(...output);
|
||||
} else if (msg.role === "toolResult") {
|
||||
const textResult = msg.content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
const textResult = extractToolResultText(msg.content);
|
||||
const sanitizedTextResult = sanitizeSurrogates(textResult);
|
||||
const hasImages = msg.content.some((c): c is ImageContent => c.type === "image");
|
||||
const mediaPlaceholder = describeToolResultMediaPlaceholder(msg.content);
|
||||
const hasText = sanitizedTextResult.trim().length > 0;
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
|
||||
@@ -419,6 +417,11 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
type: "input_text",
|
||||
text: sanitizedTextResult,
|
||||
});
|
||||
} else if (mediaPlaceholder === "(see attached media)") {
|
||||
contentParts.push({
|
||||
type: "input_text",
|
||||
text: mediaPlaceholder,
|
||||
});
|
||||
}
|
||||
|
||||
for (const block of msg.content) {
|
||||
@@ -433,10 +436,7 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
|
||||
output = contentParts;
|
||||
} else {
|
||||
output = sanitizeToolResultText(
|
||||
textResult,
|
||||
hasImages ? IMAGE_TOOL_RESULT_TEXT : EMPTY_TOOL_RESULT_TEXT,
|
||||
);
|
||||
output = sanitizeToolResultText(textResult, mediaPlaceholder ?? EMPTY_TOOL_RESULT_TEXT);
|
||||
}
|
||||
|
||||
messages.push({
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js";
|
||||
|
||||
describe("extractToolResultText", () => {
|
||||
it("redacts structured secret fields with the shared tool-payload contract", () => {
|
||||
const text = extractToolResultText([
|
||||
{
|
||||
type: "json",
|
||||
apiToken: "api-token-value-1234567890",
|
||||
privateKey: "private-key-value-1234567890",
|
||||
private_key: "private-key-snake-1234567890",
|
||||
key: "generic-key-value-1234567890",
|
||||
keyMaterial: "key-material-value-1234567890",
|
||||
bearerToken: "bearer-token-value-1234567890",
|
||||
bearer_token: "bearer-token-snake-value-1234567890",
|
||||
jwt: "jwt-value-1234567890",
|
||||
session: "session-value-1234567890",
|
||||
code: "code-value-1234567890",
|
||||
error: { code: "ERR_VISIBLE_PROVIDER_CODE" },
|
||||
oauth: { code: "OPAQUEPROVIDERCODE1234567890" },
|
||||
providerError: { error: { code: "ERR_VISIBLE_PROVIDER_NESTED_CODE" } },
|
||||
signature: "signature-value-1234567890",
|
||||
cookie: "cookie-value-1234567890",
|
||||
"set-cookie": "set-cookie-value-1234567890",
|
||||
paymentCredential: "payment-credential-value-1234567890",
|
||||
cardNumber: 4111111111111111,
|
||||
cvc: 123,
|
||||
text: '{"apiToken":"api-token-in-text-1234567890","code":"oauth-code-in-text-1234567890","safe":"ok"}',
|
||||
credential: "live-credential-value",
|
||||
appSecret: "app-secret-value",
|
||||
rawSecret: "raw-secret-value",
|
||||
nested: {
|
||||
token: "nested-token-value",
|
||||
visible: "safe-value",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(text).toContain('"credential":"');
|
||||
expect(text).toContain('"appSecret":"');
|
||||
expect(text).toContain('"rawSecret":"');
|
||||
expect(text).toContain('"token":"');
|
||||
expect(text).toContain('"visible":"safe-value"');
|
||||
expect(text).toContain('"code":"ERR_VISIBLE_PROVIDER_CODE"');
|
||||
expect(text).toContain('"code":"ERR_VISIBLE_PROVIDER_NESTED_CODE"');
|
||||
expect(text).not.toContain("api-token-value-1234567890");
|
||||
expect(text).not.toContain("private-key-value-1234567890");
|
||||
expect(text).not.toContain("private-key-snake-1234567890");
|
||||
expect(text).not.toContain("generic-key-value-1234567890");
|
||||
expect(text).not.toContain("key-material-value-1234567890");
|
||||
expect(text).not.toContain("bearer-token-value-1234567890");
|
||||
expect(text).not.toContain("bearer-token-snake-value-1234567890");
|
||||
expect(text).not.toContain("jwt-value-1234567890");
|
||||
expect(text).not.toContain("session-value-1234567890");
|
||||
expect(text).not.toContain("code-value-1234567890");
|
||||
expect(text).not.toContain("OPAQUEPROVIDERCODE1234567890");
|
||||
expect(text).not.toContain("signature-value-1234567890");
|
||||
expect(text).not.toContain("cookie-value-1234567890");
|
||||
expect(text).not.toContain("set-cookie-value-1234567890");
|
||||
expect(text).not.toContain("payment-credential-value-1234567890");
|
||||
expect(text).not.toContain("4111111111111111");
|
||||
expect(text).not.toContain('"cvc":123');
|
||||
expect(text).not.toContain("api-token-in-text-1234567890");
|
||||
expect(text).not.toContain("oauth-code-in-text-1234567890");
|
||||
expect(text).toContain('\\"safe\\":\\"ok\\"');
|
||||
expect(text).not.toContain("live-credential-value");
|
||||
expect(text).not.toContain("app-secret-value");
|
||||
expect(text).not.toContain("raw-secret-value");
|
||||
expect(text).not.toContain("nested-token-value");
|
||||
});
|
||||
|
||||
it("keeps media-only blocks out of provider replay text", () => {
|
||||
const text = extractToolResultText([
|
||||
{ type: "text", text: "summary" },
|
||||
{ type: "image", data: "image-binary", mimeType: "image/png" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,abc123" } },
|
||||
{ type: "input_image", image_url: "data:image/png;base64,def456" },
|
||||
{ type: "audio", data: "audio-binary", mimeType: "audio/mpeg" },
|
||||
]);
|
||||
|
||||
expect(text).toBe("summary");
|
||||
expect(text).not.toContain("image-binary");
|
||||
expect(text).not.toContain("abc123");
|
||||
expect(text).not.toContain("def456");
|
||||
expect(text).not.toContain("audio-binary");
|
||||
});
|
||||
|
||||
it("omits MIME-tagged binary data while preserving textual resource data", () => {
|
||||
const text = extractToolResultText([
|
||||
{ type: "resource", mime_type: "application/octet-stream", data: "AAECAwQFBgc=" },
|
||||
{ type: "resource", mediaType: "application/json", data: '{"ok":true}' },
|
||||
]);
|
||||
|
||||
expect(text).toContain('"data":"[binary data omitted: 12 chars]"');
|
||||
expect(text).toContain('{\\"ok\\":true}');
|
||||
expect(text).not.toContain("AAECAwQFBgc=");
|
||||
});
|
||||
|
||||
it("redacts inline data URIs without touching ordinary data-colon prose", () => {
|
||||
const text = extractToolResultText([
|
||||
{
|
||||
type: "json",
|
||||
value: {
|
||||
note: "metadata:ready",
|
||||
prose: "data: is ordinary prose",
|
||||
preview: "thumbnail=data:image/png;base64,abcdef done",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(text).toContain("metadata:ready");
|
||||
expect(text).toContain("data: is ordinary prose");
|
||||
expect(text).toContain("[inline data URI:");
|
||||
expect(text).not.toContain("abcdef");
|
||||
});
|
||||
|
||||
it("omits opaque or binary structured fields", () => {
|
||||
const text = extractToolResultText([
|
||||
{
|
||||
type: "json",
|
||||
encrypted_content: "ciphertext",
|
||||
bytes: [1, 2, 3],
|
||||
visible: "safe-value",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(text).toContain('"encrypted_content":"[omitted encrypted_content]"');
|
||||
expect(text).toContain('"bytes":"[omitted bytes]"');
|
||||
expect(text).toContain('"visible":"safe-value"');
|
||||
expect(text).not.toContain("ciphertext");
|
||||
});
|
||||
|
||||
it("uses structured replay only as a no-text fallback without capping explicit text", () => {
|
||||
const textTail = "explicit-tail-marker";
|
||||
const text = extractToolResultText([
|
||||
{ type: "text", text: `${"x".repeat(8_200)}${textTail}` },
|
||||
{ type: "json", internal: "extra structured detail" },
|
||||
]);
|
||||
|
||||
expect(text).toContain(textTail);
|
||||
expect(text).not.toContain("…(truncated)…");
|
||||
expect(text).not.toContain("extra structured detail");
|
||||
});
|
||||
|
||||
it("truncates structured fallback text before provider replay", () => {
|
||||
const tail = "tail-marker";
|
||||
const text = extractToolResultText([
|
||||
{
|
||||
type: "json",
|
||||
data: {
|
||||
payload: `${"x".repeat(8_200)}${tail}`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(text.length).toBeLessThan(8_100);
|
||||
expect(text).toContain("…(truncated)…");
|
||||
expect(text).not.toContain(tail);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeToolResultMediaPlaceholder", () => {
|
||||
it("describes image-only tool result media", () => {
|
||||
expect(
|
||||
describeToolResultMediaPlaceholder([{ type: "image", mimeType: "image/png", data: "img" }]),
|
||||
).toBe("(see attached image)");
|
||||
});
|
||||
|
||||
it("describes audio-only tool result media", () => {
|
||||
expect(
|
||||
describeToolResultMediaPlaceholder([
|
||||
{ type: "audio", mimeType: "audio/mpeg", data: "audio" },
|
||||
]),
|
||||
).toBe("(see attached audio)");
|
||||
});
|
||||
|
||||
it("describes mixed image and audio tool result media", () => {
|
||||
expect(
|
||||
describeToolResultMediaPlaceholder([
|
||||
{ type: "image", mimeType: "image/png", data: "img" },
|
||||
{ type: "audio", mimeType: "audio/mpeg", data: "audio" },
|
||||
]),
|
||||
).toBe("(see attached media)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { redactSecrets, redactToolPayloadText } from "../../logging/redact.js";
|
||||
import { truncateUtf16Safe } from "../../shared/utf16-slice.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
|
||||
const PROVIDER_TOOL_RESULT_MAX_CHARS = 8000;
|
||||
const IMAGE_TOOL_RESULT_TYPES = new Set(["image", "image_url", "input_image"]);
|
||||
const AUDIO_TOOL_RESULT_TYPES = new Set(["audio", "input_audio", "output_audio"]);
|
||||
const MEDIA_ONLY_TOOL_RESULT_TYPES = new Set([
|
||||
...IMAGE_TOOL_RESULT_TYPES,
|
||||
...AUDIO_TOOL_RESULT_TYPES,
|
||||
]);
|
||||
const INLINE_DATA_URI_PATTERN =
|
||||
/(^|[^A-Za-z0-9_])data:([a-z][a-z0-9.+-]*\/[a-z0-9.+-]+(?:;[a-z0-9.+-]+=[^,;"'\s]+|;base64)*,[^\s"'<>)]+)/gi;
|
||||
const MIME_KEY_CANDIDATES = [
|
||||
"mimeType",
|
||||
"mime_type",
|
||||
"mediaType",
|
||||
"media_type",
|
||||
"contentType",
|
||||
"content_type",
|
||||
];
|
||||
const TEXTUAL_MIME_PATTERN =
|
||||
/^(?:text\/|application\/(?:json|ld\+json|x-ndjson|xml|javascript|x-www-form-urlencoded)|[^/]+\/[^+]+\+(?:json|xml)$)/i;
|
||||
const OPAQUE_OR_BINARY_FIELD_RE = /^(?:blob|buffer|bytes|encrypted_content|encrypted_stdout)$/i;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readMimeType(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
for (const key of MIME_KEY_CANDIDATES) {
|
||||
const mimeType = value[key];
|
||||
if (typeof mimeType === "string" && mimeType.trim().length > 0) {
|
||||
return mimeType;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isBinaryMimeType(mimeType: string): boolean {
|
||||
const normalized = mimeType.split(";", 1)[0]?.trim().toLowerCase();
|
||||
return normalized ? !TEXTUAL_MIME_PATTERN.test(normalized) : false;
|
||||
}
|
||||
|
||||
function describeOmittedValue(value: unknown, label: string): string {
|
||||
const length = typeof value === "string" ? value.length : JSON.stringify(value)?.length;
|
||||
return length ? `[${label} omitted: ${length} chars]` : `[${label} omitted]`;
|
||||
}
|
||||
|
||||
function redactInlineDataUris(value: string): string {
|
||||
return value.replace(
|
||||
INLINE_DATA_URI_PATTERN,
|
||||
(_match, prefix: string, uri: string) => `${prefix}[inline data URI: ${uri.length} chars]`,
|
||||
);
|
||||
}
|
||||
|
||||
function redactStructuredTextValue(value: string): string {
|
||||
const redacted = redactToolPayloadText(value);
|
||||
const trimmed = redacted.trim();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
||||
return redacted;
|
||||
}
|
||||
try {
|
||||
const redactedWrapper = redactSecrets({ structuredTextValue: JSON.parse(redacted) });
|
||||
return JSON.stringify(redactedWrapper.structuredTextValue);
|
||||
} catch {
|
||||
return redacted;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyStructuredBlock(block: Record<string, unknown>): string | undefined {
|
||||
const seen = new WeakSet<object>();
|
||||
try {
|
||||
const redactedWrapper = redactSecrets({ structuredToolResult: block });
|
||||
const redactedBlock = redactedWrapper.structuredToolResult;
|
||||
const serialized = JSON.stringify(
|
||||
redactedBlock,
|
||||
function structuredToolResultReplacer(this: unknown, key, value) {
|
||||
if (OPAQUE_OR_BINARY_FIELD_RE.test(key)) {
|
||||
return `[omitted ${key}]`;
|
||||
}
|
||||
if (key === "data") {
|
||||
const mimeType = readMimeType(this);
|
||||
if (mimeType && isBinaryMimeType(mimeType)) {
|
||||
return describeOmittedValue(value, "binary data");
|
||||
}
|
||||
}
|
||||
if (typeof value === "bigint") {
|
||||
return value.toString();
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return redactInlineDataUris(redactStructuredTextValue(value));
|
||||
}
|
||||
if (typeof value === "function" || typeof value === "symbol" || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
return value;
|
||||
},
|
||||
);
|
||||
if (!serialized || serialized === "{}") {
|
||||
return undefined;
|
||||
}
|
||||
return serialized;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateProviderToolText(text: string): string {
|
||||
if (text.length <= PROVIDER_TOOL_RESULT_MAX_CHARS) {
|
||||
return text;
|
||||
}
|
||||
return `${truncateUtf16Safe(text, PROVIDER_TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||
}
|
||||
|
||||
export function describeToolResultMediaPlaceholder(blocks: readonly unknown[]): string | undefined {
|
||||
let hasImage = false;
|
||||
let hasAudio = false;
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
const type = typeof record.type === "string" ? record.type : undefined;
|
||||
const mimeType = readMimeType(record);
|
||||
|
||||
if (
|
||||
(type && IMAGE_TOOL_RESULT_TYPES.has(type)) ||
|
||||
mimeType?.toLowerCase().startsWith("image/")
|
||||
) {
|
||||
hasImage = true;
|
||||
}
|
||||
if (
|
||||
(type && AUDIO_TOOL_RESULT_TYPES.has(type)) ||
|
||||
mimeType?.toLowerCase().startsWith("audio/")
|
||||
) {
|
||||
hasAudio = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasImage && hasAudio) {
|
||||
return "(see attached media)";
|
||||
}
|
||||
if (hasAudio) {
|
||||
return "(see attached audio)";
|
||||
}
|
||||
if (hasImage) {
|
||||
return "(see attached image)";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractToolResultBlockText(block: unknown): string | undefined {
|
||||
if (!block || typeof block !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (typeof record.type === "string" && MEDIA_ONLY_TOOL_RESULT_TYPES.has(record.type)) {
|
||||
return undefined;
|
||||
}
|
||||
if (record.type === "text") {
|
||||
const text = typeof record.text === "string" ? record.text : "";
|
||||
return text ? sanitizeSurrogates(text) : undefined;
|
||||
}
|
||||
const structured = stringifyStructuredBlock(record);
|
||||
return structured ? sanitizeSurrogates(truncateProviderToolText(structured)) : undefined;
|
||||
}
|
||||
|
||||
export function extractToolResultText(blocks: readonly unknown[]): string {
|
||||
const explicitTexts: string[] = [];
|
||||
const structuredTexts: string[] = [];
|
||||
for (const block of blocks) {
|
||||
const text = extractToolResultBlockText(block);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "text") {
|
||||
explicitTexts.push(text);
|
||||
} else {
|
||||
structuredTexts.push(text);
|
||||
}
|
||||
}
|
||||
if (explicitTexts.length > 0) {
|
||||
return sanitizeSurrogates(explicitTexts.join("\n"));
|
||||
}
|
||||
return sanitizeSurrogates(truncateProviderToolText(structuredTexts.join("\n")));
|
||||
}
|
||||
@@ -1148,6 +1148,69 @@ describe("redactSecrets", () => {
|
||||
expect(serialized).not.toContain("opaque-access-token-value");
|
||||
expect(serialized).not.toContain("opaque-refresh-token-value");
|
||||
});
|
||||
|
||||
it("keeps structured error codes while redacting OAuth authorization codes", () => {
|
||||
const output = redactSecrets({
|
||||
manifest: {
|
||||
sourceFiles: { session: "$WORKSPACE_DIR/session.jsonl" },
|
||||
warnings: [
|
||||
{ code: "invalid-runtime-event" },
|
||||
{ code: "cyclic-session-branch" },
|
||||
{ code: "incomplete-session-branch" },
|
||||
],
|
||||
},
|
||||
status: { code: "SYSTEM_RUN_DENIED" },
|
||||
invalidRequestStatus: { status: { code: "INVALID_REQUEST" } },
|
||||
details: { error: { code: "SYSTEM_RUN_DENIED" } },
|
||||
nodeError: { code: "NOT_PAIRED" },
|
||||
policyError: { error: { code: "POLICY_DENIED" } },
|
||||
invalidRequestDetails: { error: { code: "INVALID_REQUEST" } },
|
||||
error: { code: "ERR_ROOTOPAQUECODE1234567890" },
|
||||
diagnostic: { error: { code: "ERR_DIAGNOSTIC_TEST" } },
|
||||
stabilityBundle: { error: { code: "ERR_STABILITY_TEST" } },
|
||||
trajectoryExport: { error: { code: "ERR_TRAJECTORY_TEST" } },
|
||||
oauth: { code: "oauth-code-value-1234567890" },
|
||||
oauthNestedError: { error: { code: "ERR_OPAQUEOAUTHCODE1234567890" } },
|
||||
provider: { code: "provider-code-value-1234567890" },
|
||||
providerAuth: { code: "provider-auth-code-value-1234567890" },
|
||||
bearerToken: "bearer-token-value-1234567890",
|
||||
bearer_token: "bearer-token-snake-value-1234567890",
|
||||
providerDetails: { error: { code: "SYSTEM_RUN_DENIED" } },
|
||||
providerNestedError: { error: { code: "ERR_PROVIDEROPAQUECODE1234567890" } },
|
||||
numericSecrets: { cardNumber: 4111111111111111, cvc: 123, token: 1234567890, amount: 4200 },
|
||||
});
|
||||
|
||||
expect(output.manifest.sourceFiles.session).toBe("$WORKSPACE_DIR/session.jsonl");
|
||||
expect(output.manifest.warnings).toEqual([
|
||||
{ code: "invalid-runtime-event" },
|
||||
{ code: "cyclic-session-branch" },
|
||||
{ code: "incomplete-session-branch" },
|
||||
]);
|
||||
expect(output.status.code).toBe("SYSTEM_RUN_DENIED");
|
||||
expect(output.invalidRequestStatus.status.code).toBe("INVALID_REQUEST");
|
||||
expect(output.details.error.code).toBe("SYSTEM_RUN_DENIED");
|
||||
expect(output.nodeError.code).toBe("NOT_PAIRED");
|
||||
expect(output.policyError.error.code).toBe("POLICY_DENIED");
|
||||
expect(output.invalidRequestDetails.error.code).toBe("INVALID_REQUEST");
|
||||
expect(output.error.code).toBe("ERR_ROOTOPAQUECODE1234567890");
|
||||
expect(output.diagnostic.error.code).toBe("ERR_DIAGNOSTIC_TEST");
|
||||
expect(output.stabilityBundle.error.code).toBe("ERR_STABILITY_TEST");
|
||||
expect(output.trajectoryExport.error.code).toBe("ERR_TRAJECTORY_TEST");
|
||||
expect(output.oauth.code).not.toBe("oauth-code-value-1234567890");
|
||||
expect(output.oauthNestedError.error.code).toBe("ERR_OPAQUEOAUTHCODE1234567890");
|
||||
expect(output.provider.code).not.toBe("provider-code-value-1234567890");
|
||||
expect(output.providerAuth.code).not.toBe("provider-auth-code-value-1234567890");
|
||||
expect(output.bearerToken).not.toBe("bearer-token-value-1234567890");
|
||||
expect(output.bearer_token).not.toBe("bearer-token-snake-value-1234567890");
|
||||
expect(output.providerDetails.error.code).toBe("SYSTEM_RUN_DENIED");
|
||||
expect(output.providerNestedError.error.code).toBe("ERR_PROVIDEROPAQUECODE1234567890");
|
||||
expect(output.numericSecrets).toEqual({
|
||||
cardNumber: "***",
|
||||
cvc: "***",
|
||||
token: "***",
|
||||
amount: 4200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSensitiveLines", () => {
|
||||
|
||||
+55
-6
@@ -88,9 +88,10 @@ const FORM_BODY_LINE_BREAK_SPLIT_RE = /(\r\n|\r|\n)/u;
|
||||
const FORM_BODY_LINE_BREAK_SEGMENT_RE = /^(?:\r\n|\r|\n)$/u;
|
||||
const PAYMENT_CREDENTIAL_JSON_KEYS = String.raw`cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token`;
|
||||
const STRUCTURED_SECRET_FIELD_RE = new RegExp(
|
||||
String.raw`^(?:api[-_]?key|apiKey|token|secret|password|passwd|credential|authorization|private[-_]?key|privateKey|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|id[-_]?token|idToken|auth[-_]?token|authToken|client[-_]?secret|clientSecret|app[-_]?secret|appSecret|secret[-_]?value|secretValue|raw[-_]?secret|rawSecret|secret[-_]?input|secretInput|key[-_]?material|keyMaterial|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`,
|
||||
String.raw`^(?:api[-_]?key|apiKey|api[-_]?token|apiToken|bearer[-_]?token|bearerToken|token|secret|password|passwd|credential|authorization|private[-_]?key|privateKey|access[-_]?token|accessToken|refresh[-_]?token|refreshToken|id[-_]?token|idToken|auth[-_]?token|authToken|client[-_]?secret|clientSecret|app[-_]?secret|appSecret|secret[-_]?value|secretValue|raw[-_]?secret|rawSecret|secret[-_]?input|secretInput|key|key[-_]?material|keyMaterial|jwt|session|signature|cookie|set[-_]?cookie|${PAYMENT_CREDENTIAL_QUERY_KEYS}|${PAYMENT_CREDENTIAL_JSON_KEYS})$`,
|
||||
"i",
|
||||
);
|
||||
const STRUCTURED_INTERNAL_SOURCE_PATH_VALUE_RE = /^\$WORKSPACE_DIR\/[A-Za-z0-9._/-]+\.jsonl$/u;
|
||||
const STRUCTURED_APP_PASSWORD_FIELD_RE =
|
||||
/^(?:apple|icloud|app[-_]?specific[-_]?password|appSpecificPassword|application[-_]?password|text|content|message|error|errorMessage|detail|details|reason)$/i;
|
||||
const APP_SPECIFIC_PASSWORD_RE = /\b([a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4})\b/g;
|
||||
@@ -143,7 +144,7 @@ const DEFAULT_REDACT_PATTERNS: string[] = [
|
||||
// lower-case URL secrets stay redacted without hiding config-key diagnostics.
|
||||
String.raw`/[?&](?:${AUTH_QUERY_KEYS}|${PAYMENT_CREDENTIAL_QUERY_KEYS})=([^&#\s<>]+)/gi`,
|
||||
// JSON fields.
|
||||
String.raw`"(?:apiKey|api_key|token|secret|password|passwd|credential|authorization|accessToken|access_token|refreshToken|refresh_token|idToken|id_token|authToken|auth_token|clientSecret|client_secret|privateKey|private_key|secret_value|raw_secret|secret_input|key_material|${PAYMENT_CREDENTIAL_JSON_KEYS})"\s*:\s*"([^"]+)"`,
|
||||
String.raw`"(?:apiKey|api_key|apiToken|api_token|bearerToken|bearer_token|token|secret|password|passwd|credential|authorization|accessToken|access_token|refreshToken|refresh_token|idToken|id_token|authToken|auth_token|clientSecret|client_secret|privateKey|private_key|secret_value|raw_secret|secret_input|key_material|${PAYMENT_CREDENTIAL_JSON_KEYS})"\s*:\s*"([^"]+)"`,
|
||||
// HTTP client diagnostics often stringify request config objects using
|
||||
// JSON or util.inspect-style fields rather than env/CLI syntax.
|
||||
String.raw`(^|[\s,{])["']?(?:api[-_]key|access[-_]token|refresh[-_]token|id[-_]token|authToken|auth[-_]token|clientSecret|client[-_]secret|appSecret|app[-_]secret|private[-_]key|credential|authorization|secret[-_]value|raw[-_]secret|secret[-_]input|key[-_]material)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2`,
|
||||
@@ -1038,6 +1039,7 @@ function redactSensitiveFieldValueWithOptions(
|
||||
key: string,
|
||||
value: string,
|
||||
options: RedactOptions,
|
||||
path: readonly string[] = [key],
|
||||
): string {
|
||||
const resolved = resolveRedactOptions(options);
|
||||
if (resolved.mode === "off") {
|
||||
@@ -1056,6 +1058,16 @@ function redactSensitiveFieldValueWithOptions(
|
||||
if (redacted !== value) {
|
||||
return redacted;
|
||||
}
|
||||
const normalizedStructuredKey = key.toLowerCase();
|
||||
if (shouldRedactStructuredAuthorizationCode(normalizedStructuredKey, path)) {
|
||||
return maskToken(value);
|
||||
}
|
||||
if (
|
||||
normalizedStructuredKey === "session" &&
|
||||
STRUCTURED_INTERNAL_SOURCE_PATH_VALUE_RE.test(value)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (isSensitiveFieldKey(key)) {
|
||||
if (isShellReferenceToKey(key, value)) {
|
||||
return value;
|
||||
@@ -1085,6 +1097,39 @@ export function redactSensitiveFieldValueWithConfig(
|
||||
);
|
||||
}
|
||||
|
||||
function pathEndsWith(path: readonly string[], suffix: readonly string[]): boolean {
|
||||
if (path.length < suffix.length) {
|
||||
return false;
|
||||
}
|
||||
return suffix.every((part, index) => path[path.length - suffix.length + index] === part);
|
||||
}
|
||||
|
||||
function shouldRedactStructuredAuthorizationCode(
|
||||
normalizedKey: string,
|
||||
path: readonly string[],
|
||||
): boolean {
|
||||
if (normalizedKey !== "code") {
|
||||
return false;
|
||||
}
|
||||
const normalizedPath = path.map((part) => part.toLowerCase());
|
||||
if (
|
||||
normalizedPath.length === 1 ||
|
||||
pathEndsWith(normalizedPath, ["error", "code"]) ||
|
||||
pathEndsWith(normalizedPath, ["nodeerror", "code"]) ||
|
||||
pathEndsWith(normalizedPath, ["status", "code"]) ||
|
||||
pathEndsWith(normalizedPath, ["details", "code"]) ||
|
||||
pathEndsWith(normalizedPath, ["warnings", "code"])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldRedactStructuredPrimitiveField(key: string, path: readonly string[]): boolean {
|
||||
const normalizedKey = key.toLowerCase();
|
||||
return shouldRedactStructuredAuthorizationCode(normalizedKey, path) || isSensitiveFieldKey(key);
|
||||
}
|
||||
|
||||
function isPlainRedactableObject(value: object): value is Record<string, unknown> {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
@@ -1095,22 +1140,23 @@ function redactStructuredSecretValue(
|
||||
value: unknown,
|
||||
seen: WeakSet<object>,
|
||||
options: RedactOptions,
|
||||
path: readonly string[] = key ? [key] : [],
|
||||
): unknown {
|
||||
if (typeof value === "string") {
|
||||
return redactSensitiveFieldValueWithOptions(key, value, options);
|
||||
return redactSensitiveFieldValueWithOptions(key, value, options, path);
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return value;
|
||||
return shouldRedactStructuredPrimitiveField(key, path) ? "***" : value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
const out = value.map((entry) => redactStructuredSecretValue(key, entry, seen, options));
|
||||
const out = value.map((entry) => redactStructuredSecretValue(key, entry, seen, options, path));
|
||||
seen.delete(value);
|
||||
return out;
|
||||
}
|
||||
@@ -1124,7 +1170,10 @@ function redactStructuredSecretValue(
|
||||
seen.add(value);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [nestedKey, nestedValue] of Object.entries(value)) {
|
||||
out[nestedKey] = redactStructuredSecretValue(nestedKey, nestedValue, seen, options);
|
||||
out[nestedKey] = redactStructuredSecretValue(nestedKey, nestedValue, seen, options, [
|
||||
...path,
|
||||
nestedKey,
|
||||
]);
|
||||
}
|
||||
seen.delete(value);
|
||||
return out;
|
||||
|
||||
@@ -5,6 +5,10 @@ export { buildGuardedModelFetch } from "../agents/provider-transport-fetch.js";
|
||||
export { buildOpenAICompletionsParams } from "../agents/openai-transport-stream.js";
|
||||
export { stripSystemPromptCacheBoundary } from "../agents/system-prompt-cache-boundary.js";
|
||||
export { transformTransportMessages } from "../agents/transport-message-transform.js";
|
||||
export {
|
||||
describeToolResultMediaPlaceholder,
|
||||
extractToolResultText,
|
||||
} from "../llm/providers/tool-result-text.js";
|
||||
export {
|
||||
coerceTransportToolCallArguments,
|
||||
createEmptyTransportUsage,
|
||||
|
||||
@@ -717,6 +717,64 @@ describe("plugin sdk alias helpers", () => {
|
||||
expect(subpaths).toEqual(["core", "qa-channel", "qa-channel-protocol", "qa-lab", "qa-runtime"]);
|
||||
});
|
||||
|
||||
it("resolves public QA plugin-sdk aliases without enabling private QA mode", () => {
|
||||
const fixture = createPluginSdkAliasFixture({
|
||||
packageExports: {
|
||||
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
|
||||
"./plugin-sdk/qa-live-transport-scenarios": {
|
||||
default: "./dist/plugin-sdk/qa-live-transport-scenarios.js",
|
||||
},
|
||||
"./plugin-sdk/qa-runner-runtime": { default: "./dist/plugin-sdk/qa-runner-runtime.js" },
|
||||
},
|
||||
});
|
||||
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
|
||||
const sourceQaRunnerPath = path.join(fixture.root, "src", "plugin-sdk", "qa-runner-runtime.ts");
|
||||
const distQaLiveTransportScenariosPath = path.join(
|
||||
fixture.root,
|
||||
"dist",
|
||||
"plugin-sdk",
|
||||
"qa-live-transport-scenarios.js",
|
||||
);
|
||||
const sourcePrivateQaRuntimePath = path.join(
|
||||
fixture.root,
|
||||
"src",
|
||||
"plugin-sdk",
|
||||
"qa-runtime.ts",
|
||||
);
|
||||
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
|
||||
fs.writeFileSync(sourceQaRunnerPath, "export const qaRunnerRuntime = true;\n", "utf-8");
|
||||
fs.writeFileSync(
|
||||
distQaLiveTransportScenariosPath,
|
||||
"export const qaLiveTransportScenarios = true;\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(sourcePrivateQaRuntimePath, "export const qaRuntime = true;\n", "utf-8");
|
||||
const sourcePluginEntry = writePluginEntry(
|
||||
fixture.root,
|
||||
bundledPluginFile("demo", "src/index.ts"),
|
||||
);
|
||||
|
||||
const subpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined }, () =>
|
||||
listPluginSdkExportedSubpaths({ modulePath: sourcePluginEntry }),
|
||||
);
|
||||
const aliases = withEnv(
|
||||
{ OPENCLAW_ENABLE_PRIVATE_QA_CLI: undefined, NODE_ENV: undefined },
|
||||
() => buildPluginLoaderAliasMap(sourcePluginEntry),
|
||||
);
|
||||
|
||||
expect(subpaths).toEqual(["core", "qa-live-transport-scenarios", "qa-runner-runtime"]);
|
||||
expect(fs.realpathSync(aliases["openclaw/plugin-sdk"] ?? "")).toBe(
|
||||
fs.realpathSync(sourceRootAlias),
|
||||
);
|
||||
expect(fs.realpathSync(aliases["openclaw/plugin-sdk/qa-runner-runtime"] ?? "")).toBe(
|
||||
fs.realpathSync(sourceQaRunnerPath),
|
||||
);
|
||||
expect(fs.realpathSync(aliases["openclaw/plugin-sdk/qa-live-transport-scenarios"] ?? "")).toBe(
|
||||
fs.realpathSync(distQaLiveTransportScenariosPath),
|
||||
);
|
||||
expect(aliases["openclaw/plugin-sdk/qa-runtime"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds non-QA private Codex helper subpaths only for trusted Codex plugins", () => {
|
||||
const fixture = createPluginSdkAliasFixture({
|
||||
packageExports: {
|
||||
|
||||
Reference in New Issue
Block a user