mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 18:31:39 +08:00
Compare commits
1 Commits
dev/kevinl
...
codex/4910
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02da4c9f53 |
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -30,6 +30,11 @@ describe("markdownToTelegramHtml", () => {
|
||||
"<script>nope</script>",
|
||||
"<script>nope</script>",
|
||||
],
|
||||
[
|
||||
"escapes literal reasoning-looking tags",
|
||||
"Before <think>literal tag text after",
|
||||
"Before <think>literal tag text after",
|
||||
],
|
||||
["escapes unsafe characters", "a & b < c", "a & b < c"],
|
||||
["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"],
|
||||
["renders lists without block HTML", "- one\n- two", "• one\n• two"],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -73,6 +73,10 @@ export function splitTelegramReasoningText(
|
||||
return {};
|
||||
}
|
||||
|
||||
if (isReasoning !== true) {
|
||||
return { answerText: text };
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (isPartialReasoningTagPrefix(trimmed)) {
|
||||
return {};
|
||||
|
||||
@@ -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 <think>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" } });
|
||||
|
||||
|
||||
@@ -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: [" "],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user