Compare commits

..

1 Commits

Author SHA1 Message Date
Ayaan Zaidi
02da4c9f53 fix(telegram): preserve literal reasoning tags 2026-06-27 13:15:47 -07:00
23 changed files with 510 additions and 747 deletions

View File

@@ -18,11 +18,7 @@ describe("Codex app inventory cache", () => {
} satisfies v2.AppsListResponse;
});
const key = buildCodexAppInventoryCacheKey(
{ codexHome: "/codex", authProfileId: "work" },
"2026.6.27",
"2026.6.27",
);
const key = buildCodexAppInventoryCacheKey({ codexHome: "/codex", authProfileId: "work" });
const read = cache.read({ key, request, nowMs: 0 });
expect(read.state).toBe("missing");
expect(read.refreshScheduled).toBe(true);
@@ -37,14 +33,6 @@ describe("Codex app inventory cache", () => {
expect(fresh.snapshot?.apps.map((item) => item.id)).toEqual(["app-1", "app-2"]);
});
it("changes the cache key when either build version changes", () => {
const input = { codexHome: "/codex", authProfileId: "work" };
const baseline = buildCodexAppInventoryCacheKey(input, "2026.6.27", "2026.6.27");
expect(buildCodexAppInventoryCacheKey(input, "2026.6.28", "2026.6.27")).not.toBe(baseline);
expect(buildCodexAppInventoryCacheKey(input, "2026.6.27", "2026.6.28")).not.toBe(baseline);
});
it("can read missing inventory without scheduling app/list", async () => {
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
const request = vi.fn(async () => {
@@ -88,7 +76,10 @@ describe("Codex app inventory cache", () => {
expect(snapshot.apps.map((item) => item.id)).toEqual(["app-1", "google-calendar-app"]);
expect(request).toHaveBeenCalledTimes(2);
expect(request.mock.calls.map(([, params]) => params.cursor ?? null)).toEqual([null, "page-2"]);
expect(request.mock.calls.map(([, params]) => params.cursor ?? null)).toEqual([
null,
"page-2",
]);
});
it("uses stale inventory for the current read while still refreshing asynchronously", async () => {

View File

@@ -252,15 +252,9 @@ export function serializeCodexAppInventoryError(error: unknown): Record<string,
/** Shared app inventory cache used by Codex app-server runtime paths. */
export const defaultCodexAppInventoryCache = new CodexAppInventoryCache();
/** Builds a stable cache key from build versions and runtime identity fields. */
export function buildCodexAppInventoryCacheKey(
input: CodexAppInventoryCacheKeyInput,
openClawVersion: string,
codexPluginVersion: string,
): string {
/** Builds a stable cache key from runtime identity fields. */
export function buildCodexAppInventoryCacheKey(input: CodexAppInventoryCacheKeyInput): string {
return JSON.stringify({
openClawVersion,
codexPluginVersion,
codexHome: input.codexHome ?? null,
endpoint: input.endpoint ?? null,
runtimeIdentity: normalizeRuntimeIdentityForCacheKey(input.runtimeIdentity),

View File

@@ -3,19 +3,13 @@
* auth, account, and version inputs without storing secret material.
*/
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { OPENCLAW_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import {
buildCodexAppInventoryCacheKey,
type CodexAppInventoryCacheKeyInput,
} from "./app-inventory-cache.js";
import { resolveCodexAppServerHomeDir } from "./auth-bridge.js";
import type { CodexAppServerRuntimeIdentity } from "./client.js";
import type { CodexAppServerRuntimeOptions, CodexAppServerStartOptions } from "./config.js";
const require = createRequire(import.meta.url);
const CODEX_PLUGIN_VERSION = readPluginPackageVersion({ require });
import type { CodexAppServerRuntimeIdentity } from "./client.js";
/** Inputs that identify the Codex app inventory cache scope for one runtime. */
export type CodexPluginAppCacheKeyParams = Omit<
@@ -29,21 +23,17 @@ export type CodexPluginAppCacheKeyParams = Omit<
/** Builds the full app inventory cache key for Codex plugin/app discovery. */
export function buildCodexPluginAppCacheKey(params: CodexPluginAppCacheKeyParams): string {
return buildCodexAppInventoryCacheKey(
{
codexHome:
params.runtimeIdentity?.codexHome ??
resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir),
endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer),
authProfileId: params.authProfileId,
accountId: params.accountId,
envApiKeyFingerprint: params.envApiKeyFingerprint,
appServerVersion: params.appServerVersion ?? params.runtimeIdentity?.serverVersion,
runtimeIdentity: params.runtimeIdentity,
},
OPENCLAW_VERSION,
CODEX_PLUGIN_VERSION,
);
return buildCodexAppInventoryCacheKey({
codexHome:
params.runtimeIdentity?.codexHome ??
resolveCodexPluginAppCacheCodexHome(params.appServer, params.agentDir),
endpoint: resolveCodexPluginAppCacheEndpoint(params.appServer),
authProfileId: params.authProfileId,
accountId: params.accountId,
envApiKeyFingerprint: params.envApiKeyFingerprint,
appServerVersion: params.appServerVersion ?? params.runtimeIdentity?.serverVersion,
runtimeIdentity: params.runtimeIdentity,
});
}
/** Builds a durable thread-binding fingerprint for one initialized app-server runtime. */

View File

@@ -3608,10 +3608,13 @@ describe("dispatchTelegramMessage draft streaming", () => {
await run;
});
it("suppresses reasoning-only finals without raw text fallback", async () => {
it("suppresses typed reasoning-only finals without raw text fallback", async () => {
setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 });
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "<think>hidden</think>" }, { kind: "final" });
await dispatcherOptions.deliver(
{ text: "<think>hidden</think>", isReasoning: true },
{ kind: "final" },
);
return { queuedFinal: true };
});
@@ -3621,6 +3624,25 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(editMessageTelegram).not.toHaveBeenCalled();
});
it("keeps unflagged angle-bracket text visible on the answer lane", async () => {
const { answerDraftStream } = setupDraftStreams({
answerMessageId: 2001,
reasoningMessageId: 3001,
});
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver(
{ text: "Before <think>literal tag text after" },
{ kind: "final" },
);
return { queuedFinal: true };
});
await dispatchWithContext({ context: createContext() });
expect(answerDraftStream.update).toHaveBeenCalledWith("Before <think>literal tag text after");
expect(deliverReplies).not.toHaveBeenCalled();
});
it("does not add silent fallback when source delivery is message-tool-only", async () => {
setupDraftStreams({ answerMessageId: 2001, reasoningMessageId: 3001 });
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({

View File

@@ -30,6 +30,11 @@ describe("markdownToTelegramHtml", () => {
"<script>nope</script>",
"&lt;script&gt;nope&lt;/script&gt;",
],
[
"escapes literal reasoning-looking tags",
"Before <think>literal tag text after",
"Before &lt;think&gt;literal tag text after",
],
["escapes unsafe characters", "a & b < c", "a &amp; b &lt; c"],
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
["renders lists without block HTML", "- one\n- two", "• one\n• two"],

View File

@@ -3,10 +3,23 @@ import { describe, expect, it } from "vitest";
import { splitTelegramReasoningText } from "./reasoning-lane-coordinator.js";
describe("splitTelegramReasoningText", () => {
it("splits real tagged reasoning and answer", () => {
expect(splitTelegramReasoningText("<think>example</think>Done")).toEqual({
it("keeps unflagged angle-bracket reasoning tags in the answer lane", () => {
const text = "<think>example</think>Done";
expect(splitTelegramReasoningText(text)).toEqual({
answerText: text,
});
});
it("keeps unclosed unflagged reasoning-looking text in the answer lane", () => {
const text = "Before <think>unclosed content after";
expect(splitTelegramReasoningText(text)).toEqual({
answerText: text,
});
});
it("formats tagged text when the payload is explicitly reasoning", () => {
expect(splitTelegramReasoningText("<think>example</think>Done", true)).toEqual({
reasoningText: "Thinking\n\n_example_",
answerText: "Done",
});
});
@@ -25,7 +38,7 @@ describe("splitTelegramReasoningText", () => {
});
it("does not emit partial reasoning tag prefixes", () => {
expect(splitTelegramReasoningText(" <thi")).toStrictEqual({});
expect(splitTelegramReasoningText(" <thi", true)).toStrictEqual({});
});
it("keeps visible Thinking-prefixed answers in the answer lane", () => {

View File

@@ -73,6 +73,10 @@ export function splitTelegramReasoningText(
return {};
}
if (isReasoning !== true) {
return { answerText: text };
}
const trimmed = text.trim();
if (isPartialReasoningTagPrefix(trimmed)) {
return {};

View File

@@ -1170,6 +1170,22 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
});
it("escapes literal reasoning-looking tags on the text path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
await sendMessageTelegram("123", "Before <think>literal tag text after", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botApi.sendMessage).toHaveBeenCalledWith(
"123",
"Before &lt;think&gt;literal tag text after",
{ parse_mode: "HTML" },
);
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
});
it("escapes HTML media tags on the text path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });

View File

@@ -71,6 +71,91 @@ describe("buildEmbeddedRunPayloads tool-error warnings", () => {
expectSinglePayloadText(payloads, "Done.");
});
it("does not revive signed unphased text when explicit final-answer text is empty", () => {
expectNoPayloads({
lastAssistant: {
role: "assistant",
stopReason: "stop",
content: [
{
type: "text",
text: "MEDIA:/tmp/old.png",
textSignature: JSON.stringify({ v: 1, id: "item_old" }),
},
{
type: "text",
text: " ",
textSignature: JSON.stringify({
v: 1,
id: "item_final",
phase: "final_answer",
}),
},
],
} as AssistantMessage,
});
});
it("does not revive signed unphased text when explicit output_text final-answer text is empty", () => {
expectNoPayloads({
lastAssistant: {
role: "assistant",
stopReason: "stop",
content: [
{
type: "text",
text: "MEDIA:/tmp/old.png",
textSignature: JSON.stringify({ v: 1, id: "item_old" }),
},
{
type: "output_text",
text: " ",
textSignature: JSON.stringify({
v: 1,
id: "item_final",
phase: "final_answer",
}),
},
],
} as AssistantMessage,
});
});
it("keeps literal mid-answer reasoning-looking tags in final-answer text", () => {
const text = "Before <think>literal tag text after";
const payloads = buildPayloads({
lastAssistant: {
role: "assistant",
stopReason: "stop",
content: [
{
type: "text",
text,
textSignature: JSON.stringify({
v: 1,
id: "item_final",
phase: "final_answer",
}),
},
],
} as AssistantMessage,
});
expectSinglePayloadText(payloads, text);
});
it("keeps strict reasoning-tag stripping for legacy string fallback text", () => {
const payloads = buildPayloads({
lastAssistant: {
role: "assistant",
stopReason: "stop",
content: "Visible prefix <think>private reasoning tail",
} as AssistantMessage,
});
expectSinglePayloadText(payloads, "Visible prefix");
});
it("falls back to final-answer assistant text when streamed text only contains blanks", () => {
const payloads = buildPayloads({
assistantTexts: [" "],

View File

@@ -24,7 +24,14 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { hasReplyPayloadContent } from "../../../interactive/payload.js";
import type { AssistantMessage } from "../../../llm/types.js";
import { isCronSessionKey } from "../../../routing/session-key.js";
import { extractAssistantTextForPhase } from "../../../shared/chat-message-content.js";
import {
extractAssistantTextForPhase,
parseAssistantTextSignature,
} from "../../../shared/chat-message-content.js";
import {
sanitizeAssistantFinalAnswerText,
sanitizeAssistantVisibleText,
} from "../../../shared/text/assistant-visible-text.js";
import { parseInlineDirectives } from "../../../utils/directive-tags.js";
import {
BILLING_ERROR_USER_MESSAGE,
@@ -112,14 +119,62 @@ function isVerboseToolDetailEnabled(level?: VerboseLevel): boolean {
return level === "full";
}
function isAssistantTextContentBlockType(value: unknown): boolean {
return value === "text" || value === "input_text" || value === "output_text";
}
function resolveRawAssistantAnswerText(lastAssistant: AssistantMessage | undefined): string {
if (!lastAssistant) {
return "";
}
const finalAnswerText = extractAssistantTextForPhase(lastAssistant, {
phase: "final_answer",
sanitizeText: sanitizeAssistantFinalAnswerText,
});
if (finalAnswerText) {
return normalizeOptionalString(finalAnswerText) ?? "";
}
if (Array.isArray(lastAssistant.content)) {
const hasExplicitPhasedTextBlock = lastAssistant.content.some((block) => {
if (!block || typeof block !== "object") {
return false;
}
const record = block as { type?: unknown; textSignature?: unknown };
return (
isAssistantTextContentBlockType(record.type) &&
Boolean(parseAssistantTextSignature(record.textSignature)?.phase)
);
});
if (!hasExplicitPhasedTextBlock) {
const signedUnphasedParts = lastAssistant.content
.map((block) => {
if (!block || typeof block !== "object") {
return null;
}
const record = block as { type?: unknown; text?: unknown; textSignature?: unknown };
const signature = parseAssistantTextSignature(record.textSignature);
if (
!isAssistantTextContentBlockType(record.type) ||
typeof record.text !== "string" ||
!signature?.id ||
signature.phase
) {
return null;
}
const text = sanitizeAssistantFinalAnswerText(record.text);
return text.trim() ? text : null;
})
.filter((value): value is string => typeof value === "string");
if (signedUnphasedParts.length) {
return normalizeOptionalString(signedUnphasedParts.join("\n")) ?? "";
}
}
}
return (
normalizeOptionalString(
extractAssistantTextForPhase(lastAssistant, { phase: "final_answer" }) ??
extractAssistantTextForPhase(lastAssistant),
extractAssistantTextForPhase(lastAssistant, {
sanitizeText: sanitizeAssistantVisibleText,
}),
) ?? ""
);
}

View File

@@ -102,9 +102,11 @@ function createMessageEndContext(
finalizeAssistantTexts?: ReturnType<typeof vi.fn>;
flushBlockReplyBuffer?: ReturnType<typeof vi.fn>;
consumeReplyDirectives?: ReturnType<typeof vi.fn>;
stripBlockTags?: ReturnType<typeof vi.fn>;
warn?: ReturnType<typeof vi.fn>;
builtinToolNames?: ReadonlySet<string>;
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
enforceFinalTag?: boolean;
blockChunker?: { hasBuffered: () => boolean; reset: () => void };
state?: Record<string, unknown>;
} = {},
@@ -119,6 +121,7 @@ function createMessageEndContext(
...(params.sourceReplyDeliveryMode
? { sourceReplyDeliveryMode: params.sourceReplyDeliveryMode }
: {}),
...(params.enforceFinalTag !== undefined ? { enforceFinalTag: params.enforceFinalTag } : {}),
...(params.onAgentEvent ? { onAgentEvent: params.onAgentEvent } : {}),
...(params.onBlockReply ? { onBlockReply: params.onBlockReply } : { onBlockReply: vi.fn() }),
},
@@ -156,7 +159,7 @@ function createMessageEndContext(
commitAssistantUsage: vi.fn(),
log: { debug: vi.fn(), info: vi.fn(), warn: params.warn ?? vi.fn() },
builtinToolNames: params.builtinToolNames,
stripBlockTags: (text: string) => text,
stripBlockTags: params.stripBlockTags ?? vi.fn((text: string) => text),
finalizeAssistantTexts: params.finalizeAssistantTexts ?? vi.fn(),
emitAssistantStreamData: vi.fn(
(data: Parameters<EmbeddedAgentSubscribeContext["emitAssistantStreamData"]>[0]) => {
@@ -1160,6 +1163,76 @@ describe("handleMessageEnd", () => {
});
});
it("preserves literal reasoning-looking tags in unphased final visible text", () => {
const onAgentEvent = vi.fn();
const stripBlockTags = vi.fn(() => "Before");
const ctx = createMessageEndContext({
onAgentEvent,
stripBlockTags,
consumeReplyDirectives: vi.fn((text: string) => ({ text })),
state: {
blockBuffer: "",
deltaBuffer: "",
},
});
void handleMessageEnd(ctx, {
type: "message_end",
message: {
role: "assistant",
content: [
{
type: "text",
text: "Before <think>literal tag text after",
textSignature: JSON.stringify({ v: 1, id: "item_unphased" }),
},
],
usage: { input: 10, output: 5, total: 15 },
},
} as never);
expect(stripBlockTags).not.toHaveBeenCalled();
expect(firstMockArg(ctx.emitAssistantStreamData as never, "assistant stream")).toMatchObject({
text: "Before <think>literal tag text after",
delta: "Before <think>literal tag text after",
});
expect(ctx.finalizeAssistantTexts).toHaveBeenCalledWith(
expect.objectContaining({ text: "Before <think>literal tag text after" }),
);
});
it("keeps final-tag enforcement in message_end fallback", () => {
const onAgentEvent = vi.fn();
const stripBlockTags = vi.fn(() => "");
const ctx = createMessageEndContext({
enforceFinalTag: true,
onAgentEvent,
stripBlockTags,
consumeReplyDirectives: vi.fn((text: string) => ({ text })),
state: {
blockBuffer: "",
deltaBuffer: "",
},
});
void handleMessageEnd(ctx, {
type: "message_end",
message: {
role: "assistant",
content: "Hello world",
usage: { input: 10, output: 5, total: 15 },
},
} as never);
expect(stripBlockTags).toHaveBeenCalledWith(
"Hello world",
{ thinking: false, final: false },
{ final: true },
);
expect(ctx.emitAssistantStreamData).not.toHaveBeenCalled();
expect(ctx.finalizeAssistantTexts).toHaveBeenCalledWith(expect.objectContaining({ text: "" }));
});
it("emits a replacement final assistant event when final_answer appears only at message_end", () => {
const onAgentEvent = vi.fn();
const ctx = createMessageEndContext({

View File

@@ -942,9 +942,12 @@ export function handleMessageEnd(
ctx.params.sourceReplyDeliveryMode === "message_tool_only" &&
ctx.builtinToolNames?.has("message") === true,
}) ?? rawVisibleText;
const finalVisibleText = ctx.params.enforceFinalTag
? ctx.stripBlockTags(visibleText, { thinking: false, final: false }, { final: true })
: visibleText;
const text = resolveSilentReplyFallbackText({
text: ctx.stripBlockTags(visibleText, { thinking: false, final: false }, { final: true }),
text: finalVisibleText,
messagingToolSentTexts: ctx.state.messagingToolSentTexts,
});
const rawThinking =

View File

@@ -790,6 +790,23 @@ describe("extractAssistantVisibleText", () => {
expect(extractAssistantVisibleText(msg)).toBe("");
});
it("does not fall back to unphased legacy text when an empty output_text final_answer block exists", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{ type: "text", text: "Legacy answer" },
{
type: "output_text",
text: " ",
textSignature: JSON.stringify({ v: 1, id: "item_final", phase: "final_answer" }),
},
],
timestamp: Date.now(),
});
expect(extractAssistantVisibleText(msg)).toBe("");
});
it("falls back to legacy unphased text when phased text is absent", () => {
const msg = makeAssistantMessage({
role: "assistant",
@@ -800,6 +817,32 @@ describe("extractAssistantVisibleText", () => {
expect(extractAssistantVisibleText(msg)).toBe("Legacy answer");
});
it("keeps strict reasoning-tag stripping for legacy string content", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: "Visible prefix <think>private reasoning tail",
timestamp: Date.now(),
});
expect(extractAssistantVisibleText(msg)).toBe("Visible prefix");
});
it("preserves literal reasoning-looking tags in unphased visible text", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "Before <think>literal tag text after",
textSignature: JSON.stringify({ v: 1, id: "item_unphased" }),
},
],
timestamp: Date.now(),
});
expect(extractAssistantVisibleText(msg)).toBe("Before <think>literal tag text after");
});
it("does not pull unphased legacy text into final_answer extraction when phased blocks are present", () => {
const msg = makeAssistantMessage({
role: "assistant",

View File

@@ -10,7 +10,10 @@ import {
parseAssistantTextSignature,
type AssistantPhase,
} from "../shared/chat-message-content.js";
import { sanitizeAssistantVisibleText } from "../shared/text/assistant-visible-text.js";
import {
sanitizeAssistantFinalAnswerText,
sanitizeAssistantVisibleText,
} from "../shared/text/assistant-visible-text.js";
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
import { sanitizeUserFacingText } from "./embedded-agent-helpers/sanitize-user-facing-text.js";
import type { AgentMessage } from "./runtime/index.js";
@@ -36,8 +39,14 @@ export function stripThinkingTagsFromText(text: string): string {
return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
}
function sanitizeAssistantText(text: string): string {
return sanitizeAssistantVisibleText(text);
function sanitizeAssistantText(text: string, phase?: AssistantPhase): string {
return phase === "final_answer"
? sanitizeAssistantFinalAnswerText(text)
: sanitizeAssistantVisibleText(text);
}
function isAssistantTextContentBlockType(value: unknown): boolean {
return value === "text" || value === "input_text" || value === "output_text";
}
export function sanitizeAssistantVisibleStreamText(text: string): string {
@@ -57,6 +66,7 @@ type AssistantTextExtractionResult = {
function extractAssistantTextForPhase(
msg: AssistantMessage,
phase?: AssistantPhase,
options?: { unphasedSignedFinalAnswer?: boolean },
): AssistantTextExtractionResult {
const messagePhase = normalizeAssistantPhase((msg as { phase?: unknown }).phase);
const shouldIncludeContent = (resolvedPhase?: AssistantPhase) => {
@@ -70,7 +80,7 @@ function extractAssistantTextForPhase(
const hadRequestedPhase = phase ? messagePhase === phase : messagePhase === undefined;
return {
text: shouldIncludeContent(messagePhase)
? finalizeAssistantExtraction(msg, sanitizeAssistantText(msg.content))
? finalizeAssistantExtraction(msg, sanitizeAssistantText(msg.content, messagePhase))
: "",
hadRequestedPhase,
};
@@ -85,37 +95,37 @@ function extractAssistantTextForPhase(
return false;
}
const record = block as { type?: unknown; textSignature?: unknown };
if (record.type !== "text") {
if (!isAssistantTextContentBlockType(record.type)) {
return false;
}
return Boolean(parseAssistantTextSignature(record.textSignature)?.phase);
});
let hadRequestedPhase = false;
const extracted =
extractTextFromChatContent(
msg.content.filter((block) => {
if (!block || typeof block !== "object") {
return false;
}
const record = block as { type?: unknown; textSignature?: unknown };
if (record.type !== "text") {
return false;
}
const signature = parseAssistantTextSignature(record.textSignature);
const resolvedPhase =
signature?.phase ?? (hasExplicitPhasedTextBlocks ? undefined : messagePhase);
if (phase ? resolvedPhase === phase : resolvedPhase === undefined) {
hadRequestedPhase = true;
}
return shouldIncludeContent(resolvedPhase);
}),
{
sanitizeText: (text) => sanitizeAssistantText(text),
joinWith: "\n",
normalizeText: (text) => text.trim(),
},
) ?? "";
const parts = msg.content
.map((block) => {
if (!block || typeof block !== "object") {
return null;
}
const record = block as { type?: unknown; text?: unknown; textSignature?: unknown };
if (!isAssistantTextContentBlockType(record.type) || typeof record.text !== "string") {
return null;
}
const signature = parseAssistantTextSignature(record.textSignature);
const resolvedPhase =
signature?.phase ?? (hasExplicitPhasedTextBlocks ? undefined : messagePhase);
if (!shouldIncludeContent(resolvedPhase)) {
return null;
}
hadRequestedPhase = true;
const sanitizerPhase =
resolvedPhase ??
(options?.unphasedSignedFinalAnswer === true && signature?.id ? "final_answer" : undefined);
const text = sanitizeAssistantText(record.text, sanitizerPhase);
return text.trim() ? text : null;
})
.filter((value): value is string => typeof value === "string");
const extracted = parts.join("\n").trim();
return {
text: finalizeAssistantExtraction(msg, extracted),
@@ -130,7 +140,7 @@ export function extractAssistantVisibleText(msg: AssistantMessage): string {
return finalAnswerExtraction.text.trim() ? finalAnswerExtraction.text : "";
}
return extractAssistantTextForPhase(msg).text;
return extractAssistantTextForPhase(msg, undefined, { unphasedSignedFinalAnswer: true }).text;
}
/** Extract sanitized assistant text across all text content blocks. */

View File

@@ -9350,7 +9350,7 @@ describe("openai transport stream", () => {
expect(output.content.some((block) => block.type === "thinking")).toBe(false);
});
it("strips content-only reasoning tags from OpenAI-compatible visible text", async () => {
it("strips content-only closed reasoning tags from OpenAI-compatible visible text", async () => {
const model = createDeepSeekCompletionsModel();
const output = createAssistantOutput(model);
@@ -9385,6 +9385,41 @@ describe("openai transport stream", () => {
expect(output.content.some((block) => block.type === "thinking")).toBe(false);
});
it("keeps content-only unclosed mid-answer reasoning-looking tags visible", async () => {
const model = createDeepSeekCompletionsModel();
const output = createAssistantOutput(model);
await testing.processOpenAICompletionsStream(
streamChunks([
{
id: "chatcmpl-content-only-unclosed-tags",
object: "chat.completion.chunk" as const,
created: 1,
model: model.id,
choices: [
{
index: 0,
delta: {
content: "Before <think>literal tag text after",
},
logprobs: null,
finish_reason: "stop" as const,
},
],
},
]),
output,
model,
{ push() {} },
);
expect(output.content).toContainEqual({
type: "text",
text: "Before <think>literal tag text after",
});
expect(output.content.some((block) => block.type === "thinking")).toBe(false);
});
it("recovers fully wrapped unclosed OpenAI-compatible reasoning text", async () => {
const model = createDeepSeekCompletionsModel();
const output = createAssistantOutput(model);

View File

@@ -87,7 +87,6 @@ import { CommandLaneClearedError, GatewayDrainingError } from "../../process/com
import { CommandLane } from "../../process/lanes.js";
import { defaultRuntime } from "../../runtime.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
import { truncateUtf16Safe } from "../../shared/utf16-slice.js";
import {
isMarkdownCapableMessageChannel,
resolveMessageChannel,
@@ -754,7 +753,7 @@ function extractCodexUsageLimitMessage(text: string): string | undefined {
if (!message) {
return undefined;
}
return message.length > 500 ? `${truncateUtf16Safe(message, 497)}...` : message;
return message.length > 500 ? `${message.slice(0, 497)}...` : message;
}
function isPureTransientRateLimitSummary(err: unknown): boolean {

View File

@@ -95,7 +95,6 @@ import { isAcpSessionKey } from "../../routing/session-key.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import { resolveSilentReplyPolicyFromPolicies } from "../../shared/silent-reply-policy.js";
import { truncateUtf16Safe } from "../../shared/utf16-slice.js";
import { createTtsDirectiveTextStreamCleaner } from "../../tts/directives.js";
import {
normalizeTtsAutoMode,
@@ -2566,7 +2565,7 @@ export async function dispatchReplyFromConfig(
if (collapsed.length <= 80) {
return collapsed;
}
return `${truncateUtf16Safe(collapsed, 77).trimEnd()}...`;
return `${collapsed.slice(0, 77).trimEnd()}...`;
};
const formatPlanUpdateText = (payload: { explanation?: string; steps?: string[] }) => {
const explanation = payload.explanation?.replace(/\s+/g, " ").trim();

View File

@@ -1,12 +1,10 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" };
import {
type OfficialExternalPluginCatalogEntry,
getOfficialExternalPluginCatalogEntry,
isOfficialExternalPluginCatalogFeed,
listOfficialExternalPluginCatalogEntries,
loadHostedOfficialExternalPluginCatalogEntries,
parseOfficialExternalPluginCatalogEntries,
resolveOfficialExternalProviderContractPluginIds,
resolveOfficialExternalProviderPluginIds,
@@ -25,16 +23,6 @@ function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry {
}
describe("official external plugin catalog", () => {
it("keeps hosted fetch guard loading lazy for bundled catalog import paths", () => {
const source = readFileSync(
new URL("./official-external-plugin-catalog.ts", import.meta.url),
"utf8",
);
expect(source).not.toMatch(/from ["']\.\.\/infra\/net\/fetch-guard\.js["']/);
expect(source).toContain('await import("../infra/net/fetch-guard.js")');
});
it("ships the official plugin catalog as a feed-shaped bundled fallback", () => {
expect(isOfficialExternalPluginCatalogFeed(officialExternalPluginCatalog)).toBe(true);
expect(officialExternalPluginCatalog).toMatchObject({
@@ -57,7 +45,7 @@ describe("official external plugin catalog", () => {
).toBe(false);
expect(
isOfficialExternalPluginCatalogFeed({
schemaVersion: 3,
schemaVersion: 2,
id: "openclaw-official-external-plugins",
generatedAt: "2026-06-22T00:00:00.000Z",
sequence: 1,
@@ -66,135 +54,10 @@ describe("official external plugin catalog", () => {
).toBe(false);
});
it("accepts the live ClawHub feed schema version", () => {
expect(
isOfficialExternalPluginCatalogFeed({
schemaVersion: 2,
id: "clawhub-official",
generatedAt: "2026-06-25T01:19:39.629Z",
sequence: 11,
entries: [],
}),
).toBe(true);
});
it("keeps live ClawHub marketplace entries as metadata-only feed entries", () => {
const [entry] = parseOfficialExternalPluginCatalogEntries({
schemaVersion: 2,
id: "clawhub-official",
generatedAt: "2026-06-25T01:19:39.629Z",
sequence: 11,
entries: [
{
type: "plugin",
id: "@expediagroup/expedia-openclaw",
title: "Expedia Travel",
version: "1.0.4",
state: "available",
publisher: {
id: "expediagroup",
trust: "official",
},
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@expediagroup/expedia-openclaw",
version: "1.0.4",
integrity:
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
},
],
},
},
],
});
if (entry === undefined) {
throw new Error("Expected hosted ClawHub feed entry to parse");
}
expect(entry).toMatchObject({
id: "@expediagroup/expedia-openclaw",
title: "Expedia Travel",
version: "1.0.4",
});
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
});
it("does not synthesize trusted installs for unavailable or untrusted hosted entries", () => {
const entries = parseOfficialExternalPluginCatalogEntries({
schemaVersion: 2,
id: "clawhub-official",
generatedAt: "2026-06-25T01:19:39.629Z",
sequence: 11,
entries: [
{
type: "plugin",
id: "@example/unavailable",
title: "Unavailable",
version: "1.0.0",
state: "disabled",
publisher: { id: "example", trust: "official" },
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@example/unavailable",
version: "1.0.0",
},
],
},
},
{
type: "plugin",
id: "@example/community",
title: "Community",
version: "1.0.0",
state: "available",
publisher: { id: "example", trust: "community" },
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@example/community",
version: "1.0.0",
},
],
},
},
{
type: "plugin",
id: "@example/private-source",
title: "Private Source",
version: "1.0.0",
state: "available",
publisher: { id: "example", trust: "official" },
install: {
candidates: [
{
sourceRef: "private-feed",
package: "@example/private-source",
version: "1.0.0",
},
],
},
},
],
});
expect(entries).toHaveLength(3);
for (const entry of entries) {
expect(resolveOfficialExternalPluginId(entry)).toBeUndefined();
expect(resolveOfficialExternalPluginInstall(entry)).toBeNull();
}
});
it("keeps unsupported versioned feed wrappers out of legacy catalog parsing", () => {
expect(
parseOfficialExternalPluginCatalogEntries({
schemaVersion: 3,
schemaVersion: 2,
id: "future-feed",
generatedAt: "2026-06-22T00:00:00.000Z",
sequence: 1,
@@ -208,182 +71,6 @@ describe("official external plugin catalog", () => {
).toEqual([{ name: "legacy-catalog-entry" }]);
});
it("loads a hosted feed with conditional headers and checksum metadata", async () => {
const body = JSON.stringify({
schemaVersion: 1,
id: "openclaw-official-external-plugins",
generatedAt: "2026-06-22T00:00:00.000Z",
sequence: 2,
entries: [
{
name: "@openclaw/hosted-proof",
kind: "plugin",
openclaw: {
plugin: { id: "hosted-proof", label: "Hosted Proof" },
install: { npmSpec: "@openclaw/hosted-proof", defaultChoice: "npm" },
},
},
],
});
const fetchImpl = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers);
expect(headers.get("if-none-match")).toBe('"old"');
expect(headers.get("if-modified-since")).toBe("Mon, 22 Jun 2026 00:00:00 GMT");
return new Response(body, {
status: 200,
headers: {
etag: '"next"',
"last-modified": "Mon, 22 Jun 2026 01:00:00 GMT",
"content-length": String(new TextEncoder().encode(body).byteLength),
},
});
});
const result = await loadHostedOfficialExternalPluginCatalogEntries({
fetchImpl,
ifNoneMatch: '"old"',
ifModifiedSince: "Mon, 22 Jun 2026 00:00:00 GMT",
});
expect(result.source).toBe("hosted");
expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/hosted-proof"]);
if (result.source === "hosted") {
expect(result.feed.sequence).toBe(2);
expect(result.metadata).toMatchObject({
status: 200,
etag: '"next"',
lastModified: "Mon, 22 Jun 2026 01:00:00 GMT",
});
expect(result.metadata.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
}
});
it("keeps live ClawHub metadata-only entries after hosted feed loading", async () => {
const body = JSON.stringify({
schemaVersion: 2,
id: "clawhub-official",
generatedAt: "2026-06-25T01:19:39.629Z",
sequence: 11,
entries: [
{
type: "plugin",
id: "@expediagroup/expedia-openclaw",
title: "Expedia Travel",
version: "1.0.4",
state: "available",
publisher: {
id: "expediagroup",
trust: "official",
},
install: {
candidates: [
{
sourceRef: "public-clawhub",
package: "@expediagroup/expedia-openclaw",
version: "1.0.4",
integrity:
"sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9",
},
],
},
},
],
});
const result = await loadHostedOfficialExternalPluginCatalogEntries({
fetchImpl: vi.fn(
async () =>
new Response(body, {
status: 200,
headers: {
"content-length": String(new TextEncoder().encode(body).byteLength),
},
}),
),
});
expect(result.source).toBe("hosted");
expect(result.entries).toHaveLength(1);
expect(result.entries[0]).toMatchObject({
id: "@expediagroup/expedia-openclaw",
title: "Expedia Travel",
version: "1.0.4",
});
});
it("falls back to the bundled catalog when hosted feed validation fails", async () => {
const result = await loadHostedOfficialExternalPluginCatalogEntries({
fetchImpl: vi.fn(
async () =>
new Response(JSON.stringify({ schemaVersion: 1, id: " ", entries: [] }), {
status: 200,
}),
),
});
expect(result.source).toBe("bundled-fallback");
expect(result.entries.length).toBe(listOfficialExternalPluginCatalogEntries().length);
if (result.source === "bundled-fallback") {
expect(result.error).toContain("supported schema version");
expect(result.metadata?.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
}
});
it("falls back to the bundled catalog on HTTP 304 until a snapshot cache exists", async () => {
const result = await loadHostedOfficialExternalPluginCatalogEntries({
fetchImpl: vi.fn(
async () =>
new Response(null, {
status: 304,
headers: { etag: '"same"', "last-modified": "Mon, 22 Jun 2026 01:00:00 GMT" },
}),
),
});
expect(result.source).toBe("bundled-fallback");
if (result.source === "bundled-fallback") {
expect(result.error).toContain("without a cached snapshot");
expect(result.metadata).toMatchObject({
status: 304,
etag: '"same"',
lastModified: "Mon, 22 Jun 2026 01:00:00 GMT",
});
}
});
it("falls back to the bundled catalog on checksum mismatch and oversized bodies", async () => {
const mismatch = await loadHostedOfficialExternalPluginCatalogEntries({
expectedSha256: "sha256:not-current",
fetchImpl: vi.fn(
async () =>
new Response(
JSON.stringify({
schemaVersion: 1,
id: "openclaw-official-external-plugins",
generatedAt: "2026-06-22T00:00:00.000Z",
sequence: 1,
entries: [],
}),
{ status: 200 },
),
),
});
expect(mismatch.source).toBe("bundled-fallback");
if (mismatch.source === "bundled-fallback") {
expect(mismatch.error).toContain("checksum mismatch");
expect(mismatch.metadata?.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
}
const oversized = await loadHostedOfficialExternalPluginCatalogEntries({
maxBytes: 4,
fetchImpl: vi.fn(async () => new Response("12345", { status: 200 })),
});
expect(oversized.source).toBe("bundled-fallback");
if (oversized.source === "bundled-fallback") {
expect(oversized.error).toContain("exceeds 4 bytes");
}
});
it("lists the externalized provider and capability plugins with install metadata", () => {
const providers = [
["arcee", "@openclaw/arcee-provider"],

View File

@@ -1,5 +1,4 @@
/** Reads official external plugin/channel/provider catalogs into manifest-like metadata. */
import { createHash } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import officialExternalChannelCatalog from "../../scripts/lib/official-external-channel-catalog.json" with { type: "json" };
@@ -78,34 +77,16 @@ export type OfficialExternalPluginCatalogManifest = {
/** Raw official external catalog entry loaded from generated catalog JSON. */
export type OfficialExternalPluginCatalogEntry = {
id?: string;
title?: string;
type?: string;
state?: string;
publisher?: {
id?: string;
trust?: string;
};
name?: string;
version?: string;
description?: string;
source?: string;
kind?: string;
install?: {
candidates?: readonly OfficialExternalPluginCatalogInstallCandidate[];
};
} & Partial<Record<ManifestKey, OfficialExternalPluginCatalogManifest>>;
export type OfficialExternalPluginCatalogInstallCandidate = {
sourceRef?: string;
package?: string;
version?: string;
integrity?: string;
};
/** Feed-shaped wrapper used by the bundled external plugin catalog fallback. */
export type OfficialExternalPluginCatalogFeed = {
schemaVersion: 1 | 2;
schemaVersion: number;
id: string;
generatedAt: string;
sequence: number;
@@ -113,32 +94,6 @@ export type OfficialExternalPluginCatalogFeed = {
entries: readonly OfficialExternalPluginCatalogEntry[];
};
export type HostedOfficialExternalPluginCatalogMetadata = {
url: string;
status: number;
etag?: string;
lastModified?: string;
checksum: string;
};
export type HostedOfficialExternalPluginCatalogLoadResult =
| {
source: "hosted";
entries: OfficialExternalPluginCatalogEntry[];
feed: OfficialExternalPluginCatalogFeed;
metadata: HostedOfficialExternalPluginCatalogMetadata;
}
| {
source: "bundled-fallback";
entries: OfficialExternalPluginCatalogEntry[];
error: string;
metadata?: Omit<HostedOfficialExternalPluginCatalogMetadata, "checksum"> & {
checksum?: string;
};
};
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
type OfficialExternalProviderContract =
| "embeddingProviders"
| "mediaUnderstandingProviders"
@@ -152,13 +107,7 @@ const OFFICIAL_CATALOG_SOURCES = [
officialExternalPluginCatalog,
] as const;
const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS = new Set<unknown>([1, 2]);
export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL =
"https://clawhub.ai/v1/feeds/plugins";
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS = 5000;
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES = 1024 * 1024;
const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS = 5000;
const OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST = ["clawhub.ai"];
const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSION = 1;
export function isOfficialExternalPluginCatalogFeed(
raw: unknown,
@@ -167,9 +116,8 @@ export function isOfficialExternalPluginCatalogFeed(
return false;
}
const sequence = raw.sequence;
const entries = raw.entries;
return (
OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS.has(raw.schemaVersion) &&
raw.schemaVersion === OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSION &&
typeof raw.id === "string" &&
raw.id.trim().length > 0 &&
typeof raw.generatedAt === "string" &&
@@ -177,7 +125,7 @@ export function isOfficialExternalPluginCatalogFeed(
typeof sequence === "number" &&
Number.isInteger(sequence) &&
sequence >= 0 &&
Array.isArray(entries)
Array.isArray(raw.entries)
);
}
@@ -205,288 +153,6 @@ export function parseOfficialExternalPluginCatalogEntries(
return list.filter((entry): entry is OfficialExternalPluginCatalogEntry => isRecord(entry));
}
function normalizeHostedCatalogHeader(value: string | null): string | undefined {
const normalized = normalizeOptionalString(value);
return normalized || undefined;
}
function sha256Hex(value: string): string {
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
}
function resolveHostedCatalogFeedUrl(feedUrl: string | undefined): URL {
const raw = feedUrl?.trim() || DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL;
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error("hosted catalog feed URL is invalid");
}
if (parsed.protocol !== "https:") {
throw new Error("hosted catalog feed URL must use HTTPS");
}
if (!OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST.includes(parsed.hostname)) {
throw new Error("hosted catalog feed URL hostname is not allowed");
}
return parsed;
}
function parseHostedCatalogContentLength(raw: string | null, maxBytes: number): void {
const normalized = normalizeOptionalString(raw);
if (!normalized) {
return;
}
if (!/^\d+$/.test(normalized)) {
throw new Error("hosted catalog feed has invalid content-length");
}
const size = Number(normalized);
if (!Number.isSafeInteger(size) || size > maxBytes) {
throw new Error(`hosted catalog feed exceeds ${maxBytes} bytes`);
}
}
function hasStreamingResponseBody(
response: Response,
): response is Response & { body: ReadableStream<Uint8Array> } {
return Boolean(
response.body && typeof (response.body as { getReader?: unknown }).getReader === "function",
);
}
async function readHostedCatalogChunkWithTimeout(
reader: ReadableStreamDefaultReader<Uint8Array>,
chunkTimeoutMs: number,
): Promise<Awaited<ReturnType<typeof reader.read>>> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
return await new Promise((resolve, reject) => {
const clear = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
timeoutId = setTimeout(() => {
timedOut = true;
clear();
void reader.cancel().catch(() => undefined);
reject(new Error(`hosted catalog feed read timed out after ${chunkTimeoutMs}ms`));
}, chunkTimeoutMs);
void reader.read().then(
(result) => {
clear();
if (!timedOut) {
resolve(result);
}
},
(err: unknown) => {
clear();
if (!timedOut) {
reject(err instanceof Error ? err : new Error(String(err)));
}
},
);
});
}
async function readHostedCatalogResponseText(params: {
response: Response;
maxBytes: number;
chunkTimeoutMs: number;
}): Promise<string> {
parseHostedCatalogContentLength(params.response.headers.get("content-length"), params.maxBytes);
if (!hasStreamingResponseBody(params.response)) {
const text = await params.response.text();
if (new TextEncoder().encode(text).byteLength > params.maxBytes) {
throw new Error(`hosted catalog feed exceeds ${params.maxBytes} bytes`);
}
return text;
}
const reader = params.response.body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const chunk = await readHostedCatalogChunkWithTimeout(reader, params.chunkTimeoutMs);
if (chunk.done) {
break;
}
totalBytes += chunk.value.byteLength;
if (totalBytes > params.maxBytes) {
throw new Error(`hosted catalog feed exceeds ${params.maxBytes} bytes`);
}
chunks.push(chunk.value);
}
} catch (err) {
await reader.cancel().catch(() => undefined);
throw err;
} finally {
reader.releaseLock();
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(body);
}
function bundledOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
return OFFICIAL_CATALOG_SOURCES.flatMap((source) =>
parseOfficialExternalPluginCatalogEntries(source),
);
}
function dedupeOfficialExternalPluginCatalogEntries(
entries: OfficialExternalPluginCatalogEntry[],
): OfficialExternalPluginCatalogEntry[] {
const resolved = new Map<string, OfficialExternalPluginCatalogEntry>();
for (const entry of entries) {
const key = resolveOfficialExternalPluginCatalogEntryKey(entry);
if (key && !resolved.has(key)) {
resolved.set(key, entry);
}
}
return [...resolved.values()];
}
function resolveOfficialExternalPluginCatalogEntryKey(
entry: OfficialExternalPluginCatalogEntry,
): string | undefined {
const pluginId = resolveOfficialExternalPluginId(entry);
if (pluginId) {
return `${normalizeOptionalString(entry.kind) ?? "plugin"}:${pluginId}`;
}
const name = normalizeOptionalString(entry.name);
if (name) {
return name;
}
const id = normalizeOptionalString(entry.id);
if (id) {
return `${normalizeOptionalString(entry.kind) ?? normalizeOptionalString(entry.type) ?? "plugin"}:${id}`;
}
return undefined;
}
function bundledFallbackResult(
error: unknown,
metadata?: HostedOfficialExternalPluginCatalogLoadResult["metadata"],
): HostedOfficialExternalPluginCatalogLoadResult {
return {
source: "bundled-fallback",
entries: listOfficialExternalPluginCatalogEntries(),
error: error instanceof Error ? error.message : String(error),
...(metadata ? { metadata } : {}),
};
}
export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
feedUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
maxBytes?: number;
chunkTimeoutMs?: number;
ifNoneMatch?: string;
ifModifiedSince?: string;
expectedSha256?: string;
}): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
let url: URL;
try {
url = resolveHostedCatalogFeedUrl(params?.feedUrl);
} catch (err) {
return bundledFallbackResult(err);
}
const headers = new Headers();
const ifNoneMatch = normalizeOptionalString(params?.ifNoneMatch);
const ifModifiedSince = normalizeOptionalString(params?.ifModifiedSince);
if (ifNoneMatch) {
headers.set("if-none-match", ifNoneMatch);
}
if (ifModifiedSince) {
headers.set("if-modified-since", ifModifiedSince);
}
const metadataBase = (response: Response) => {
const etag = normalizeHostedCatalogHeader(response.headers.get("etag"));
const lastModified = normalizeHostedCatalogHeader(response.headers.get("last-modified"));
return {
url: url.href,
status: response.status,
...(etag ? { etag } : {}),
...(lastModified ? { lastModified } : {}),
};
};
let response: Response | undefined;
let release: (() => Promise<void>) | undefined;
try {
const { fetchWithSsrFGuard } = await import("../infra/net/fetch-guard.js");
const guarded = await fetchWithSsrFGuard({
url: url.href,
fetchImpl: params?.fetchImpl,
init: { method: "GET", headers },
requireHttps: true,
maxRedirects: 2,
timeoutMs: params?.timeoutMs ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS,
policy: { hostnameAllowlist: OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST },
auditContext: "official-external-plugin-catalog-feed",
});
response = guarded.response;
release = guarded.release;
const base = metadataBase(response);
if (response.status === 304) {
return bundledFallbackResult(
"hosted catalog feed returned HTTP 304 without a cached snapshot",
base,
);
}
if (!response.ok) {
return bundledFallbackResult(`hosted catalog feed returned HTTP ${response.status}`, base);
}
const body = await readHostedCatalogResponseText({
response,
maxBytes: params?.maxBytes ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES,
chunkTimeoutMs:
params?.chunkTimeoutMs ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS,
});
const checksum = sha256Hex(body);
const expectedSha256 = normalizeOptionalString(params?.expectedSha256);
if (expectedSha256 && expectedSha256 !== checksum) {
return bundledFallbackResult(
`hosted catalog feed checksum mismatch: expected ${expectedSha256}`,
{
...base,
checksum,
},
);
}
const raw = JSON.parse(body) as unknown;
if (!isOfficialExternalPluginCatalogFeed(raw)) {
return bundledFallbackResult("hosted catalog feed did not match a supported schema version", {
...base,
checksum,
});
}
return {
source: "hosted",
entries: dedupeOfficialExternalPluginCatalogEntries(
parseOfficialExternalPluginCatalogEntries(raw),
),
feed: raw,
metadata: {
...base,
checksum,
},
};
} catch (err) {
return bundledFallbackResult(err);
} finally {
if (response?.bodyUsed !== true) {
await response?.body?.cancel().catch(() => undefined);
}
await release?.().catch(() => undefined);
}
}
function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultChoice"] | undefined {
return value === "clawhub" || value === "npm" || value === "local" ? value : undefined;
}
@@ -567,7 +233,18 @@ export function resolveOfficialExternalPluginInstall(
}
export function listOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
return dedupeOfficialExternalPluginCatalogEntries(bundledOfficialExternalPluginCatalogEntries());
const entries = OFFICIAL_CATALOG_SOURCES.flatMap((source) =>
parseOfficialExternalPluginCatalogEntries(source),
);
const resolved = new Map<string, OfficialExternalPluginCatalogEntry>();
for (const entry of entries) {
const pluginId = resolveOfficialExternalPluginId(entry);
const key = pluginId ? `${entry.kind ?? "plugin"}:${pluginId}` : (entry.name ?? "");
if (key && !resolved.has(key)) {
resolved.set(key, entry);
}
}
return [...resolved.values()];
}
/** Resolves official external plugin owners for configured capability provider ids. */

View File

@@ -1,6 +1,7 @@
// Assistant visible text tests cover extracting user-visible assistant output.
import { describe, expect, it } from "vitest";
import {
sanitizeAssistantFinalAnswerText,
sanitizeAssistantVisibleText,
sanitizeAssistantVisibleTextWithProfile,
stripAssistantInternalScaffolding,
@@ -873,11 +874,26 @@ describe("sanitizeAssistantVisibleText", () => {
);
});
it("keeps unclosed trailing reasoning hidden when visible text already exists", () => {
it("hides mid-answer unclosed reasoning tags on the raw delivery path", () => {
expect(sanitizeAssistantVisibleText("Visible prefix <think>private reasoning tail")).toBe(
"Visible prefix",
);
});
it("still hides mid-answer closed reasoning tags", () => {
const text = "Visible prefix <think>private reasoning</think> visible suffix";
expect(sanitizeAssistantVisibleText(text)).toBe("Visible prefix visible suffix");
});
it("keeps unclosed literal reasoning-looking tags in final-answer prose", () => {
expect(
sanitizeAssistantFinalAnswerText("<think>hidden</think>Use <think> literally here"),
).toBe("Use <think> literally here");
expect(sanitizeAssistantFinalAnswerText("Before <think>literal tag text after")).toBe(
"Before <think>literal tag text after",
);
});
});
describe("sanitizeAssistantVisibleTextWithProfile", () => {

View File

@@ -6,6 +6,7 @@ import { stripModelSpecialTokens } from "./model-special-tokens.js";
import {
stripReasoningTagsFromText,
type ReasoningTagMode,
type ReasoningTagScope,
type ReasoningTagTrim,
} from "./reasoning-tags.js";
@@ -801,6 +802,7 @@ export function stripAssistantInternalTraceLines(text: string): string {
export type AssistantVisibleTextSanitizerProfile =
| "delivery"
| "final-answer-delivery"
| "history"
| "internal-scaffolding"
| "tool-progress";
@@ -813,6 +815,7 @@ type AssistantVisibleTextPipelineOptions = {
stripFunctionResponseAfterPluralToolCalls?: boolean;
stripInternalTraceLines?: boolean;
reasoningMode: ReasoningTagMode;
reasoningScope?: ReasoningTagScope;
reasoningTrim: ReasoningTagTrim;
stageOrder: "reasoning-first" | "reasoning-last";
};
@@ -828,6 +831,14 @@ const ASSISTANT_VISIBLE_TEXT_PIPELINE_OPTIONS: Record<
reasoningTrim: "both",
stageOrder: "reasoning-last",
},
"final-answer-delivery": {
finalTrim: "both",
stripFunctionResponseAfterPluralToolCalls: true,
reasoningMode: "strict",
reasoningScope: "leading",
reasoningTrim: "both",
stageOrder: "reasoning-last",
},
history: {
finalTrim: "none",
reasoningMode: "strict",
@@ -863,6 +874,7 @@ function applyAssistantVisibleTextStagePipeline(
const stripReasoning = (value: string) =>
stripReasoningTagsFromText(value, {
mode: options.reasoningMode,
scope: options.reasoningScope,
trim: options.reasoningTrim,
});
const applyFinalTrim = (value: string) => {
@@ -925,6 +937,11 @@ export function sanitizeAssistantVisibleText(text: string): string {
return sanitizeAssistantVisibleTextWithProfile(text, "delivery");
}
/** Sanitizes text already marked as final-answer prose by the agent runtime. */
export function sanitizeAssistantFinalAnswerText(text: string): string {
return sanitizeAssistantVisibleTextWithProfile(text, "final-answer-delivery");
}
/**
* Backwards-compatible trim wrapper.
* Prefer sanitizeAssistantVisibleTextWithProfile for new call sites.

View File

@@ -183,7 +183,7 @@ describe("createReasoningTagTextPartitioner", () => {
]);
});
it("recovers unclosed trailing tags as visible prose in visible mode", () => {
it("keeps unclosed trailing tags as visible prose in visible mode", () => {
const partitioner = createReasoningTagTextPartitioner();
expect(partitioner.pushVisible("Use <think> only in this mode")).toEqual([

View File

@@ -3,6 +3,7 @@ import { findCodeRegions, isInsideCode } from "./code-regions.js";
import { findFinalTagMatches } from "./final-tags.js";
export type ReasoningTagMode = "strict" | "preserve";
export type ReasoningTagTrim = "none" | "start" | "both";
export type ReasoningTagScope = "all" | "leading";
// Reasoning tags may carry a model-specific namespace prefix (e.g. Anthropic's
// `antml:`, MiniMax's `mm:`). Accept the known prefixes so namespaced variants
@@ -29,12 +30,31 @@ export function hasOrphanReasoningCloseBoundary(params: {
return params.before.trim().length > 0 && params.after.trim().length > 0;
}
function hasReasoningCloseTagAfter(
text: string,
start: number,
codeRegions: ReturnType<typeof findCodeRegions>,
) {
for (const match of text.slice(start).matchAll(THINKING_TAG_RE)) {
const idx = start + (match.index ?? 0);
if (isInsideCode(idx, codeRegions)) {
continue;
}
if (match[1] === "/") {
return true;
}
}
THINKING_TAG_RE.lastIndex = 0;
return false;
}
/** Strips model reasoning/final tags from visible text while preserving literal code examples. */
export function stripReasoningTagsFromText(
text: string,
options?: {
mode?: ReasoningTagMode;
trim?: ReasoningTagTrim;
scope?: ReasoningTagScope;
},
): string {
if (!text) {
@@ -46,6 +66,7 @@ export function stripReasoningTagsFromText(
const mode = options?.mode ?? "strict";
const trimMode = options?.trim ?? "both";
const scope = options?.scope ?? "all";
let cleaned = text;
const matches = findFinalTagMatches(cleaned);
@@ -92,6 +113,14 @@ export function stripReasoningTagsFromText(
}
if (thinkingDepth === 0) {
if (
scope === "leading" &&
!isClose &&
(result + cleaned.slice(lastIndex, idx)).trim().length > 0 &&
!hasReasoningCloseTagAfter(cleaned, idx + match[0].length, codeRegions)
) {
return applyTrim(result + cleaned.slice(lastIndex), trimMode);
}
if (isClose) {
const afterIndex = idx + match[0].length;
const before = cleaned.slice(lastIndex, idx);