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:
snowzlmbot
2026-06-30 23:44:02 -07:00
committed by GitHub
parent b2355ef6a2
commit b63e06f68a
30 changed files with 1514 additions and 120 deletions
@@ -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
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+167
View File
@@ -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"]],
+5 -10
View File
@@ -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,
+27
View File
@@ -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)",
},
]);
});
});
+43 -1
View File
@@ -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,
};
+2 -2
View File
@@ -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,
+118 -1
View File
@@ -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");
+36 -26
View File
@@ -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({
+7 -3
View File
@@ -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`;
+12 -9
View File
@@ -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;
+76
View File
@@ -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"],
+52 -24
View File
@@ -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"}}',
);
});
});
+4 -7
View File
@@ -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: {
+104
View File
@@ -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)");
});
});
+19 -11
View File
@@ -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"'),
}),
]),
);
});
});
+4 -6
View File
@@ -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", () => {
+9 -9
View File
@@ -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({
+185
View File
@@ -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)");
});
});
+199
View File
@@ -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")));
}
+63
View File
@@ -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
View File
@@ -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,
+58
View File
@@ -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: {