* 'main' of https://github.com/openclaw/openclaw: (29 commits)
  refactor(gateway): trim attach grant implementation
  feat(gateway): scoped attach grants for external MCP loopback clients
  fix(gateway): iOS Talk treats SecretRef-backed API keys as missing (#98210)
  test(infra): add unit tests for SQLite number normalization (#98009)
  test(config): add unit tests for resolveExecCommandHighlighting (#98087)
  test(utils): add unit tests for chunkItems (#98219)
  fix(core): propagate caller env PATHEXT through isExecutableFile on Windows (#98093)
  fix(matrix): guard JSON.parse against malformed homeserver response bodies (#97973)
  fix(sms): guard Twilio JSON.parse against malformed API response bodies (#97999)
  Add Swedish mobile app localization (#98043)
  fix(anthropic): surface Discord pre-tool commentary
  fix(tui): correct disconnect copy for device scope upgrades (#98144)
  chore(ui): refresh fa control ui locale
  chore(ui): refresh nl control ui locale
  chore(ui): refresh vi control ui locale
  chore(ui): refresh th control ui locale
  chore(ui): refresh pl control ui locale
  chore(ui): refresh uk control ui locale
  chore(ui): refresh id control ui locale
  chore(ui): refresh tr control ui locale
  ...
This commit is contained in:
Vincent Koc
2026-06-30 18:27:32 -07:00
135 changed files with 3593 additions and 827 deletions
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">OpenClaw-nod</string>
</resources>
@@ -0,0 +1 @@
"CFBundleDisplayName" = "OpenClaw-aktivitet";
@@ -0,0 +1 @@
"CFBundleDisplayName" = "Dela till OpenClaw";
@@ -0,0 +1,14 @@
"CFBundleDisplayName" = "OpenClaw";
"NSLocalNetworkUsageDescription" = "OpenClaw hittar och ansluter till din OpenClaw Gateway i det lokala nätverket.";
"NSCameraUsageDescription" = "OpenClaw kan ta bilder eller korta videoklipp när du begär det via gatewayen.";
"NSCalendarsUsageDescription" = "OpenClaw använder dina kalendrar för att visa händelser och schemainformation när du aktiverar kalenderåtkomst.";
"NSCalendarsFullAccessUsageDescription" = "OpenClaw använder dina kalendrar för att visa händelser och schemainformation när du aktiverar kalenderåtkomst.";
"NSCalendarsWriteOnlyAccessUsageDescription" = "OpenClaw använder dina kalendrar för att lägga till händelser när du aktiverar kalenderåtkomst.";
"NSContactsUsageDescription" = "OpenClaw använder dina kontakter så att du kan söka efter och hänvisa till personer när du använder assistenten.";
"NSLocationWhenInUseUsageDescription" = "OpenClaw använder din plats när du tillåter platsdelning.";
"NSLocationAlwaysAndWhenInUseUsageDescription" = "OpenClaw kan dela din plats i bakgrunden när du aktiverar Alltid.";
"NSMicrophoneUsageDescription" = "OpenClaw använder mikrofonen för realtidschatt, röstaktivering och tryck-och-prata.";
"NSMotionUsageDescription" = "OpenClaw kan använda rörelsedata för att stödja enhetsmedvetna interaktioner och automatiseringar.";
"NSPhotoLibraryUsageDescription" = "OpenClaw behöver åtkomst till bildbiblioteket när du väljer befintliga bilder att dela med din assistent.";
"NSRemindersFullAccessUsageDescription" = "OpenClaw använder dina påminnelser för att lista, lägga till och slutföra uppgifter när du aktiverar åtkomst till påminnelser.";
"NSSpeechRecognitionUsageDescription" = "OpenClaw använder taligenkänning på enheten för Talk-läge och röstaktivering.";
@@ -0,0 +1 @@
"CFBundleDisplayName" = "OpenClaw";
+1 -1
View File
@@ -44,7 +44,7 @@ Or set `APP_STORE_CONNECT_API_KEY_PATH`.
## Notes
- Locale files live under `metadata/en-US/`.
- Locale files live under `metadata/<locale>/`, for example `metadata/en-US/` and `metadata/sv-SE/`. Each locale directory should use the public metadata filenames consumed by the `ios metadata` lane.
- `release_notes.txt` is generated from `apps/ios/CHANGELOG.md`; after changelog updates, run `pnpm ios:version:sync`.
- `apps/ios/APP-REVIEW-NOTES.md` is rendered to `apps/ios/build/app-review/APP-REVIEW-NOTES.pdf` and uploaded as the App Review attachment when metadata is uploaded.
- Release notes resolve from `## <pinned iOS version>` first, then fall back to `## Unreleased` while a TestFlight train is still in progress.
@@ -0,0 +1,19 @@
OpenClaw är en personlig AI-assistent som du kör på dina egna enheter.
Parkoppla iOS-appen med din OpenClaw Gateway och använd din iPhone som en säker nod för chatt, röst, godkännanden, delning och enhetsmedveten automatisering.
Det här kan du göra:
- Parkoppla med din privata OpenClaw Gateway via QR-kod eller installationskod
- Chatta med din assistent från iPhone
- Använd Talk-läge i realtid och tryck-och-prata
- Granska åtgärder som väntar på godkännande från gatewayen
- Dela text, länkar och media direkt från iOS till OpenClaw
- Aktivera enhetsfunktioner som kamera, skärm, plats, bilder, kontakter, kalender och påminnelser när du vill
- Ta emot pushväckningar och nodstatus för anslutna arbetsflöden
OpenClaw är byggt för lokal kontroll: du styr din gateway, dina nycklar, din konfiguration och dina behörigheter. Åtkomst till enheten hanteras av iOS-behörigheter och aktiveras bara för de funktioner du vill använda.
Kom igång:
1. Konfigurera din OpenClaw Gateway
2. Öppna iOS-appen och parkoppla den med gatewayen
3. Börja använda chatt, Talk-läge, godkännanden och automatiseringar från din iPhone
@@ -0,0 +1 @@
openclaw,ai-assistent,lokal ai,iphone ai,röstassistent,automatisering,gateway,chatt,agent
@@ -0,0 +1 @@
https://openclaw.ai
@@ -0,0 +1 @@
OpenClaw - iOS-klient
@@ -0,0 +1 @@
https://openclaw.ai/privacy
@@ -0,0 +1 @@
Parkoppla din iPhone med OpenClaw Gateway för chatt, röst i realtid, godkännanden, enhetsfunktioner och privat automatisering.
@@ -0,0 +1,3 @@
OpenClaw finns nu för iPhone.
Anslut till din OpenClaw Gateway för att chatta med din assistent, använda Talk-läge i realtid, granska godkännanden, dela innehåll från iOS och använda enhetsfunktioner som kamera, plats, skärm och aviseringar i dina privata automatiseringar.
@@ -0,0 +1 @@
Personlig AI på dina enheter
@@ -0,0 +1 @@
https://docs.openclaw.ai/platforms/ios
+3 -3
View File
@@ -1,4 +1,4 @@
d88b36f186301f29b2ec01b1580609b6709b7b537ed461be84a14a66c80c5544 config-baseline.json
16ea2dff07075ea8392bbf0cee461cd2f42fb54ade2f1a09a950fd1086e82a2f config-baseline.core.json
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
5ce9c4b232d6472204e91187847bdb3204afc9e53833f6d92cf8dada2dc2ba67 config-baseline.json
00242f93938579d3ff3130f8d42e08a423e43dc7a73e3b5e5afb45b80c78d25a config-baseline.core.json
4982a9d7d070b37e64048697b0611e7ce34a1059ba0735106c07f17666ed9fc2 config-baseline.channel.json
21ce3d97ac3a83323fa29ddc35045c04f6eae3040c2c5515077f543b4608fd12 config-baseline.plugin.json
+16
View File
@@ -214,6 +214,22 @@ Supported surfaces:
- To keep preview streaming but hide tool-progress lines, set `streaming.preview.toolProgress` to `false` for that channel. To keep tool-progress lines visible while hiding command/exec text, set `streaming.preview.commandText` to `"status"` or `streaming.progress.commandText` to `"status"`; the default is `"raw"` to preserve released behavior. This policy is shared by draft/progress channels that use OpenClaw's compact progress renderer, including Discord, Matrix, Microsoft Teams, Mattermost, Slack draft previews, and Telegram. To disable preview edits entirely, set `streaming.mode` to `off`.
- Telegram selected quote replies are an exception: when `replyToMode` is not `"off"` and selected quote text is present, OpenClaw skips the answer preview stream for that turn so tool-progress preview lines cannot render. Current-message replies without selected quote text still keep preview streaming. See [Telegram channel docs](/channels/telegram) for details.
### Commentary progress lane
Beyond tool-progress, the compact progress renderer can surface one more lane in the draft:
- **`streaming.progress.commentary`** — render the model's pre-tool **commentary** (💬) — short "I'll check… then…" narration — interleaved with tool lines in the progress draft.
```json
{
"channels": {
"discord": {
"streaming": { "mode": "progress", "progress": { "commentary": true } }
}
}
}
```
Keep progress lines visible but hide raw command/exec text:
```json
+1
View File
@@ -2829,6 +2829,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: Channel mapping
- H3: Runtime behavior
- H3: Tool-progress preview updates
- H3: Commentary progress lane
- H2: Related
## concepts/system-prompt.md
+3
View File
@@ -243,6 +243,9 @@ Common scopes:
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets`
(or `operator.admin`).
When secrets are included, clients should read the active Talk provider
credential from `talk.resolved.config.apiKey`; `talk.providers.<id>.apiKey`
stays source-shaped and may be a SecretRef object or a redacted string.
Plugin-registered gateway RPC methods may request their own operator scope, but
reserved core admin prefixes (`config.*`, `exec.approvals.*`, `wizard.*`,
@@ -44,8 +44,10 @@ export function createDiscordDraftPreviewController(params: {
const accountBlockStreamingEnabled =
resolveChannelStreamingBlockEnabled(params.discordConfig) ??
params.cfg.agents?.defaults?.blockStreamingDefault === "on";
const canStreamProgressDraftForToolOnlySource =
params.sourceRepliesAreToolOnly && discordStreamMode === "progress";
const canStreamDraft =
!params.sourceRepliesAreToolOnly &&
(!params.sourceRepliesAreToolOnly || canStreamProgressDraftForToolOnlySource) &&
discordStreamMode !== "off" &&
!accountBlockStreamingEnabled;
const draftStream = canStreamDraft
@@ -89,6 +91,10 @@ export function createDiscordDraftPreviewController(params: {
mode: discordStreamMode,
active: Boolean(draftStream),
seed: progressSeed,
reasoningLinePrefix: "🧠 ",
commentaryLinePrefix: "💬 ",
reasoningGate: true,
commentaryItalics: false,
update: async (previewText, options) => {
lastPartialText = previewText;
draftText = previewText;
@@ -254,6 +260,8 @@ export function createDiscordDraftPreviewController(params: {
});
},
handleAssistantMessageBoundary() {
// Queued/followup turns need a fresh progress draft after the primary final.
progressDraft.beginNewTurn();
if (discordStreamMode === "progress") {
return;
}
@@ -132,6 +132,7 @@ type DispatchInboundParams = {
onReasoningStream?: (payload?: {
text?: string;
isReasoningSnapshot?: boolean;
requiresReasoningProgressOptIn?: boolean;
}) => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onToolStart?: (payload: {
@@ -142,7 +143,6 @@ type DispatchInboundParams = {
}) => Promise<void> | void;
onItemEvent?: (payload: {
itemId?: string;
toolCallId?: string;
kind?: string;
progressText?: string;
summary?: string;
@@ -157,8 +157,6 @@ type DispatchInboundParams = {
}) => Promise<void> | void;
onApprovalEvent?: (payload: { phase?: string; command?: string }) => Promise<void> | void;
onCommandOutput?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
name?: string;
title?: string;
@@ -185,7 +183,6 @@ type DispatchInboundParams = {
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
allowToolLifecycleWhenProgressHidden?: boolean;
onTypingCleanup?: () => Promise<void> | void;
};
};
@@ -747,6 +744,27 @@ function expectSinglePreviewEdit() {
expect(deliverDiscordReply).not.toHaveBeenCalled();
}
function expectProgressSummaryEdit(...parts: string[]) {
const call = firstMockCall(editMessageDiscord, "summary edit");
expect(call[0]).toBe("c1");
expect(call[1]).toBe("preview-1");
const edit = requireRecord(call[2], "summary edit body");
const content = String(edit.content);
expect(content.startsWith("-# ")).toBe(true);
for (const part of parts) {
expect(content).toContain(part);
}
}
function expectFreshFinalText(text: string) {
const finalParams = deliverDiscordReply.mock.calls
.map((call) => requireRecord(call[0], "deliverDiscordReply params"))
.find((params) => params.kind === "final");
expect(finalParams).toBeDefined();
const replies = (finalParams as { replies?: Array<{ text?: string }> }).replies;
expect(replies?.[0]?.text).toBe(text);
}
describe("processDiscordMessage ack reactions", () => {
it("drops bot-loop-suppressed messages before Discord side effects", async () => {
const botLoopProtection: ChannelBotLoopProtectionFacts = {
@@ -830,39 +848,6 @@ describe("processDiscordMessage ack reactions", () => {
});
});
it("records accepted mention ingress before acking and dispatching", async () => {
const events: string[] = [];
recordInboundSession.mockImplementationOnce(async () => {
events.push("record");
});
sendMocks.reactMessageDiscord.mockImplementationOnce(async () => {
events.push("ack");
});
dispatchInboundMessage.mockImplementationOnce(async () => {
events.push("dispatch");
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
accountId: "ops",
shouldRequireMention: true,
effectiveWasMentioned: true,
route: {
agentId: "main",
channel: "discord",
accountId: "ops",
sessionKey: "agent:main:discord:channel:c1",
mainSessionKey: "agent:main:main",
},
});
await runProcessDiscordMessage(ctx);
expect(events).toEqual(["record", "ack", "dispatch"]);
expect(recordInboundSession).toHaveBeenCalledTimes(1);
expect(sendMocks.reactMessageDiscord).toHaveBeenCalled();
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
});
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
const ctx = await createAutomaticSourceDeliveryContext({
message: {
@@ -1024,7 +1009,6 @@ describe("processDiscordMessage ack reactions", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBe(true);
const emojis = getReactionEmojis();
expect(emojis).toContain("👀");
expect(emojis).toContain(DEFAULT_EMOJIS.done);
@@ -1193,7 +1177,6 @@ describe("processDiscordMessage ack reactions", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.allowToolLifecycleWhenProgressHidden).toBeUndefined();
expect(getReactionEmojis()).toEqual(["👀"]);
});
@@ -2129,8 +2112,100 @@ describe("processDiscordMessage draft streaming", () => {
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toEqual(["Pinching\n\n🛠️ Exec\n• exec done"]);
expectPreviewEditContent("done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expectProgressSummaryEdit("🛠️ 1 tool call");
expectFreshFinalText("done");
});
it("counts window thinking bursts closed by a tool call when no end event fires", async () => {
// deepseek streams reasoning then a tool call with no thinking_end between
// bursts; the tool-start boundary (and the summary flush) must still tally.
createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.({ text: "Listing the workspace" });
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Picking the largest" });
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Composing the answer" });
await params?.dispatcher.sendFinalReply({ text: "done" });
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: { mode: "progress", progress: { label: "Shelling", thinking: true } },
},
});
await runProcessDiscordMessage(ctx);
// 2 bursts closed by tool calls + 1 trailing burst flushed at summary.
expectProgressSummaryEdit("🧠 3 thoughts", "🛠️ 2 tool calls");
expectFreshFinalText("done");
});
it("counts window thinking bursts in the collapse summary", async () => {
createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.({ text: "Planning the survey" });
await params?.replyOptions?.onReasoningEnd?.();
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Reading results" });
await params?.replyOptions?.onReasoningEnd?.();
// A boundary without a preceding burst must not inflate the count.
await params?.replyOptions?.onReasoningEnd?.();
await params?.dispatcher.sendFinalReply({ text: "done" });
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: { mode: "progress", progress: { label: "Shelling", thinking: true } },
},
});
await runProcessDiscordMessage(ctx);
expectProgressSummaryEdit("🧠 2 thoughts", "🛠️ 1 tool call");
expectFreshFinalText("done");
});
it("counts distinct narration notes in the collapse summary", async () => {
createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "p1",
progressText: "Listing the workspace",
});
// Re-fire of the same note (delta/snapshot) must not inflate the count.
await params?.replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "p1",
progressText: "Listing the workspace files",
});
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({
kind: "preamble",
itemId: "p2",
progressText: "Composing the answer",
});
await params?.dispatcher.sendFinalReply({ text: "done" });
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } },
},
});
await runProcessDiscordMessage(ctx);
expectProgressSummaryEdit("💬 2 notes", "🛠️ 1 tool call");
expectFreshFinalText("done");
});
it("does not update Discord progress drafts after final answer delivery", async () => {
@@ -2161,8 +2236,8 @@ describe("processDiscordMessage draft streaming", () => {
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toEqual(["Shelling\n\n🛠️ Exec\n• exec running"]);
expectPreviewEditContent("done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expectProgressSummaryEdit("🛠️ 1 tool call");
expectFreshFinalText("done");
});
it("does not update Discord progress drafts while final answer delivery is pending", async () => {
@@ -2193,16 +2268,16 @@ describe("processDiscordMessage draft streaming", () => {
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toEqual(["Shelling\n\n🛠️ Exec\n• exec running"]);
expectPreviewEditContent("done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expectProgressSummaryEdit("🛠️ 1 tool call");
expectFreshFinalText("done");
});
it("keeps Discord tool progress private for coding-profile message-tool-only guild replies", async () => {
it("streams Discord tool progress for coding-profile message-tool-only guild replies", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(
params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed,
).toBeUndefined();
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({ progressText: "exec done" });
return createNoQueuedDispatchResult();
@@ -2222,36 +2297,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(createDiscordDraftStream).not.toHaveBeenCalled();
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("preserves explicitly enabled status reactions without exposing tool progress drafts", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
expect(params?.replyOptions?.sourceReplyDeliveryMode).toBe("message_tool_only");
expect(params?.replyOptions?.allowProgressCallbacksWhenSourceDeliverySuppressed).toBe(true);
expect(params?.replyOptions?.suppressDefaultToolProgressMessages).toBe(true);
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return createNoQueuedDispatchResult();
});
const ctx = await createBaseContext({
cfg: {
tools: { profile: "coding" },
messages: {
ackReaction: "👀",
groupChat: { visibleReplies: "message_tool" },
statusReactions: { enabled: true, timing: { debounceMs: 0 } },
},
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
},
route: BASE_CHANNEL_ROUTE,
});
await runProcessDiscordMessage(ctx);
expect(getReactionEmojis()).toContain(DEFAULT_EMOJIS.done);
expect(createDiscordDraftStream).not.toHaveBeenCalled();
expect(draftStream.update).toHaveBeenCalledWith("Pinching\n\n🛠️ Exec\n• exec done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
@@ -2300,12 +2346,9 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledTimes(1);
expect(editMessageDiscord).not.toHaveBeenCalled();
expectProgressSummaryEdit("🛠️ 1 tool call");
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
const params = firstMockArg(deliverDiscordReply, "deliverDiscordReply");
const replies = requireRecord(params, "deliverDiscordReply params").replies;
expect(Array.isArray(replies)).toBe(true);
expect((replies as Array<{ text?: string }>)[0]?.text).toBe(fullAnswer);
expectFreshFinalText(fullAnswer);
});
it("clears partial drafts when fallback final delivery fails before completion", async () => {
@@ -2644,20 +2687,20 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers reasoning block payloads to Discord", async () => {
it("renders reasoning block payloads as a 🧠 blockquote", async () => {
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
await processStreamOffDiscordMessage();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "thinking...", isReasoning: true }],
replies: [{ text: "> 🧠 thinking..." }],
});
});
it("delivers reasoning-tagged final payload to Discord", async () => {
it("renders reasoning-tagged final payloads as a 🧠 blockquote, never the final", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({
text: "Reasoning:\nthis should be visible",
text: "Reasoning:\nthis renders as a quoted thinking message",
isReasoning: true,
});
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
@@ -2671,7 +2714,7 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "this should be visible", isReasoning: true }],
replies: [{ text: "> 🧠 this renders as a quoted thinking message" }],
});
});
@@ -2748,7 +2791,7 @@ describe("processDiscordMessage draft streaming", () => {
});
it.each([
["unset", undefined],
// commentary now defaults on; only an explicit false hides it.
["false", false],
])("hides Discord commentary progress when commentary is %s", async (_label, commentary) => {
const draftStream = createMockDraftStreamForTest();
@@ -2852,7 +2895,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenLastCalledWith(
"_Checking the current weather source before summarizing clearly._",
"💬 Checking the current weather source before summarizing clearly.",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
expect(updates).not.toContain("Exec");
@@ -2905,6 +2948,46 @@ describe("processDiscordMessage draft streaming", () => {
},
);
it.each([
["active", true],
["inactive", false],
])(
"renders Discord tool lines in the draft exactly when durable verbose progress is %s",
async (_label, durableLaneActive) => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
params?.replyOptions?.onVerboseProgressVisibility?.(() => durableLaneActive);
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({ progressText: "exec running" });
await params?.replyOptions?.onCommandOutput?.({
phase: "end",
title: "Exec",
name: "exec",
exitCode: 0,
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: { mode: "progress", progress: { label: "Shelling" } },
},
});
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
if (durableLaneActive) {
// The durable verbose lane persists tool summaries: the ephemeral
// draft must not render the same tool activity a second time.
expect(updates).toBe("");
} else {
expect(updates).toContain("Exec");
}
},
);
it("keeps Discord progress drafts usable after the last commentary line becomes silent", async () => {
const draftStream = createMockDraftStreamForTest();
@@ -2976,9 +3059,9 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toEqual(["_Checking source data._"]);
expectPreviewEditContent("done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(updates).toEqual(["💬 Checking source data."]);
expectProgressSummaryEdit();
expectFreshFinalText("done");
});
it("does not start Discord progress drafts for text-only accepted turns", async () => {
@@ -3028,47 +3111,8 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expectPreviewEditContent("done");
});
it("replaces Discord command progress items with matching command output", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onItemEvent?.({
itemId: "tool:call-1",
toolCallId: "call-1",
kind: "command",
name: "exec",
progressText: "install dependencies",
});
await params?.replyOptions?.onCommandOutput?.({
itemId: "tool:call-1-output",
toolCallId: "call-1",
phase: "end",
name: "exec",
exitCode: 0,
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Shelling",
},
},
},
});
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
expect(lastUpdate).toContain("install dependencies");
expect(lastUpdate).not.toContain("completed");
expectProgressSummaryEdit("🛠️ 1 tool call");
expectFreshFinalText("done");
});
it("drops later tool warning finals after progress preview final replies", async () => {
@@ -3097,10 +3141,11 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec\n• exec done");
expectPreviewEditContent("delivery survived");
expectProgressSummaryEdit("🛠️ 1 tool call");
expect(draftStream.clear).not.toHaveBeenCalled();
expect(draftStream.messageId()).toBe("preview-1");
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expectFreshFinalText("delivery survived");
});
it("uses raw tool-progress detail in Discord progress drafts", async () => {
@@ -3255,13 +3300,45 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _Reading the event projector_",
"Clawing...\n\n🛠️ Exec\n🧠 _Reading the event projector_",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("Reasoning");
expect(updates.join("\n")).not.toContain("Thinking\n");
});
it("hides non-stream reasoning progress until Discord thinking progress is enabled", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Private planning",
requiresReasoningProgressOptIn: true,
});
await params?.replyOptions?.onItemEvent?.({ progressText: "done" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith("Clawing...\n\n🛠️ Exec\n• done");
expect(draftStream.update.mock.calls.map((call) => call[0]).join("\n")).not.toContain(
"Private planning",
);
});
it("accumulates reasoning deltas in Discord progress drafts", async () => {
const draftStream = createMockDraftStreamForTest();
@@ -3279,6 +3356,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3287,7 +3365,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _Considering plugin installation!_",
"Clawing...\n\n🛠️ Exec\n🧠 _Considering plugin installation!_",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("• _!_");
@@ -3309,6 +3387,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3317,7 +3396,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _Thinking through the install plan_",
"Clawing...\n\n🛠️ Exec\n🧠 _Thinking through the install plan_",
);
});
@@ -3336,6 +3415,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3344,7 +3424,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _Thinking: compare install paths_",
"Clawing...\n\n🛠️ Exec\n🧠 _Thinking: compare install paths_",
);
});
@@ -3363,6 +3443,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3371,7 +3452,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _Reasoning: compare install paths_",
"Clawing...\n\n🛠️ Exec\n🧠 _Reasoning: compare install paths_",
);
});
@@ -3392,6 +3473,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3400,7 +3482,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _compare install paths_",
"Clawing...\n\n🛠️ Exec\n🧠 _compare install paths_",
);
});
@@ -3421,6 +3503,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3429,7 +3512,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _compare install paths_",
"Clawing...\n\n🛠️ Exec\n🧠 _compare install paths_",
);
});
@@ -3448,6 +3531,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3456,7 +3540,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _Thinking through the plan_",
"Clawing...\n\n🛠️ Exec\n🧠 _Thinking through the plan_",
);
});
@@ -3476,6 +3560,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3484,7 +3569,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _I was Thinking about the plan_",
"Clawing...\n\n🛠️ Exec\n🧠 _I was Thinking about the plan_",
);
});
@@ -3504,6 +3589,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3512,7 +3598,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _I was Thinking... through the plan_",
"Clawing...\n\n🛠️ Exec\n🧠 _I was Thinking... through the plan_",
);
});
@@ -3532,6 +3618,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -3540,7 +3627,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n _I was Reasoning: through edge cases_",
"Clawing...\n\n🛠️ Exec\n🧠 _I was Reasoning: through edge cases_",
);
});
@@ -3562,6 +3649,7 @@ describe("processDiscordMessage draft streaming", () => {
progress: {
label: "Clawing...",
maxLineChars: 36,
thinking: true,
},
},
},
@@ -3572,7 +3660,7 @@ describe("processDiscordMessage draft streaming", () => {
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
const reasoningLine = lastUpdate?.split("\n").at(-1);
expect(reasoningLine).toMatch(/^ _.*_$/u);
expect(reasoningLine).toMatch(/^🧠 _.*_$/u);
expect(reasoningLine?.match(/_/gu)).toHaveLength(2);
});
@@ -3598,6 +3686,7 @@ describe("processDiscordMessage draft streaming", () => {
mode: "progress",
progress: {
label: "Clawing...",
thinking: true,
},
},
},
@@ -21,6 +21,7 @@ import {
import {
buildChannelProgressDraftLine,
buildChannelProgressDraftLineForEntry,
isChannelProgressDraftWorkToolName,
resolveChannelStreamingBlockEnabled,
resolveTranscriptBackedChannelFinalText,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -40,6 +41,7 @@ import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtim
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { createDiscordRestClient } from "../client.js";
import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js";
import {
@@ -566,10 +568,7 @@ async function processDiscordMessageInner(
chunkMode,
log: logVerbose,
});
// While the durable verbose commentary lane is active (dispatch reports it
// via onVerboseProgressVisibility), the ephemeral draft yields its commentary
// lines so commentary is not rendered in both lanes.
let verboseProgressActive: () => boolean = () => false;
let shouldYieldDraftProgress: () => boolean = () => false;
const finalPreviewFlags =
(discordConfig?.suppressEmbeds ?? true) ? MessageFlags.SuppressEmbeds : undefined;
let finalReplyStartNotified = false;
@@ -593,6 +592,100 @@ async function processDiscordMessageInner(
draftPreview.markFinalReplyDelivered();
observer?.onFinalReplyDelivered?.();
};
// Per-line quoting survives Discord chunking; blank quote rows render badly.
const formatDiscordReasoningQuote = (quoteText: string): string | undefined => {
const lines = quoteText
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (!lines.length) {
return undefined;
}
lines[0] = `🧠 ${lines[0]}`;
return lines.map((line) => `> ${line}`).join("\n");
};
// Reasoning delivery follows the session /reasoning level, not streaming config.
const reasoningLevel = ((): "on" | "stream" | "off" => {
const normalizedAgentId = (route.agentId ?? "").trim().toLowerCase() || "main";
const agentEntryDefault = cfg.agents?.list?.find(
(entry) => ((entry?.id ?? "").trim().toLowerCase() || "main") === normalizedAgentId,
)?.reasoningDefault;
const cfgDefault = agentEntryDefault ?? cfg.agents?.defaults?.reasoningDefault;
const configDefault: "on" | "stream" | "off" =
cfgDefault === "on" || cfgDefault === "stream" ? cfgDefault : "off";
const sessionKey = ctxPayload.SessionKey;
if (!sessionKey) {
return configDefault;
}
try {
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const level = getSessionEntry({
agentId: route.agentId,
sessionKey,
storePath,
})?.reasoningLevel;
if (level === "on" || level === "stream" || level === "off") {
return level;
}
} catch {
return "off";
}
return configDefault;
})();
const reasoningDurableEnabled = reasoningLevel === "on";
const reasoningWindowEnabled = reasoningLevel === "stream";
const progressTurnStartedAt = Date.now();
let progressReasoningSteps = 0;
let progressToolCalls = 0;
let progressCommentaryNotes = 0;
// Durable reasoning posts after the draft; summary must land below it.
let persistentReasoningDelivered = false;
// Preamble updates can re-fire; count each item id or id-less text once.
const seenCommentaryIds = new Set<string>();
let lastCommentaryNoteText = "";
const noteWindowCommentary = (itemId?: string, noteText?: string) => {
const trimmed = noteText?.trim();
if (!trimmed) {
return;
}
if (itemId) {
if (seenCommentaryIds.has(itemId)) {
return;
}
seenCommentaryIds.add(itemId);
progressCommentaryNotes += 1;
return;
}
if (trimmed !== lastCommentaryNoteText) {
lastCommentaryNoteText = trimmed;
progressCommentaryNotes += 1;
}
};
// DeepSeek does not always emit a thinking_end, so tool/final boundaries also close bursts.
let windowReasoningOpen = false;
const closePendingWindowThought = () => {
if (windowReasoningOpen) {
windowReasoningOpen = false;
progressReasoningSteps += 1;
}
};
const buildProgressSummaryLine = () => {
closePendingWindowThought();
const seconds = Math.max(1, Math.round((Date.now() - progressTurnStartedAt) / 1000));
const parts = [
...(progressReasoningSteps > 0
? [`🧠 ${progressReasoningSteps} thought${progressReasoningSteps === 1 ? "" : "s"}`]
: []),
...(progressCommentaryNotes > 0
? [`💬 ${progressCommentaryNotes} note${progressCommentaryNotes === 1 ? "" : "s"}`]
: []),
...(progressToolCalls > 0
? [`🛠️ ${progressToolCalls} tool call${progressToolCalls === 1 ? "" : "s"}`]
: []),
`⏱️ ${seconds}s`,
];
return `-# ${parts.join(" · ")}`;
};
const beforeDiscordPayloadDelivery = (
payload: ReplyPayload,
info: { kind: ReplyDispatchKind },
@@ -608,6 +701,9 @@ async function processDiscordMessageInner(
);
return null;
}
if (payload.isReasoning || payload.isCommentary) {
return payload;
}
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(payload);
if (!reply.hasMedia && !payload.isError) {
@@ -639,6 +735,50 @@ async function processDiscordMessageInner(
return { visibleReplySent: false };
}
const isFinal = info.kind === "final";
if (payload.isReasoning) {
const raw = (payload.text ?? "").trim();
const body = raw.startsWith("Reasoning:\n") ? raw.slice("Reasoning:\n".length).trim() : raw;
if (!body) {
return { visibleReplySent: false };
}
const chunkLimit = Math.max(256, Math.min(textLimit, 2000) - 8);
const chunks = chunkDiscordTextWithMode(body, {
maxChars: chunkLimit,
maxLines: maxLinesPerMessage,
chunkMode,
});
const replies = (chunks.length ? chunks : [body])
.map((chunk) => formatDiscordReasoningQuote(chunk))
.filter((quote): quote is string => Boolean(quote))
.map((quote) => Object.assign({}, payload, { text: quote, isReasoning: undefined }));
if (!replies.length) {
return { visibleReplySent: false };
}
await deliverDiscordReply({
cfg,
replies,
target: deliverTarget,
token,
accountId,
rest: deliveryRest,
runtime,
replyToId: replyReference.use(),
replyToMode,
textLimit,
maxLinesPerMessage,
tableMode,
chunkMode,
sessionKey: ctxPayload.SessionKey,
threadBindings,
mediaLocalRoots,
kind: "block",
});
replyReference.markSent();
// Durable 🧠 (/reasoning on) is persisted, not streamed — never count it in the
// bar. Mark that durable reasoning posted so the collapse anchors below it.
persistentReasoningDelivered = true;
return { visibleReplySent: true };
}
if (
isFinal &&
!options?.allowFallbackOnlyToolWarning &&
@@ -684,11 +824,65 @@ async function processDiscordMessageInner(
return { visibleReplySent: false };
}
}
const shouldFinalizeDraftPreview =
const shouldCollapseProgressDraft =
draftStream &&
isFinal &&
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
draftPreview.isProgressMode &&
draftPreview.hasProgressDraftStarted &&
!deliverablePayload.isError;
if (shouldCollapseProgressDraft && draftStream) {
await draftPreview.flush();
if (persistentReasoningDelivered) {
// Keep /reasoning on order as thoughts, summary, answer.
await draftStream.clear();
await deliverDiscordReply({
cfg,
replies: [{ text: buildProgressSummaryLine() }],
target: deliverTarget,
token,
accountId,
rest: deliveryRest,
runtime,
replyToId: replyReference.use(),
replyToMode,
textLimit,
maxLinesPerMessage,
tableMode,
chunkMode,
sessionKey: ctxPayload.SessionKey,
threadBindings,
mediaLocalRoots,
kind: "block",
});
replyReference.markSent();
draftPreview.markPreviewFinalized();
} else {
const draftId = draftStream.messageId();
if (draftId !== undefined) {
await draftStream.seal();
try {
await editMessageDiscord(
deliverChannelId,
draftId,
{
content: buildProgressSummaryLine(),
...(finalPreviewFlags ? { flags: finalPreviewFlags } : {}),
},
{ cfg, accountId, rest: deliveryRest },
);
draftPreview.markPreviewFinalized();
} catch (err) {
logVerbose(
`discord: progress draft summary edit failed; clearing draft (${String(err)})`,
);
await draftStream.clear();
}
}
}
// Fall through to the generic fresh send below for the final itself.
}
const shouldFinalizeDraftPreview =
draftStream && isFinal && !draftPreview.isProgressMode && !deliverablePayload.isError;
if (shouldFinalizeDraftPreview) {
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
const hasMedia = reply.hasMedia;
@@ -968,7 +1162,9 @@ async function processDiscordMessageInner(
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
suppressTyping: isRoomEvent ? true : undefined,
allowProgressCallbacksWhenSourceDeliverySuppressed:
sourceRepliesAreToolOnly && statusReactionsExplicitlyEnabled ? true : undefined,
sourceRepliesAreToolOnly && draftPreview.draftStream && draftPreview.isProgressMode
? true
: undefined,
disableBlockStreaming: sourceRepliesAreToolOnly
? true
: (draftPreview.disableBlockStreamingForDraft ??
@@ -983,7 +1179,10 @@ async function processDiscordMessageInner(
? () => draftPreview.handleAssistantMessageBoundary()
: undefined,
onReasoningEnd: draftPreview.draftStream
? () => draftPreview.handleAssistantMessageBoundary()
? () => {
closePendingWindowThought();
return draftPreview.handleAssistantMessageBoundary();
}
: undefined,
onModelSelected,
suppressDefaultToolProgressMessages:
@@ -995,21 +1194,42 @@ async function processDiscordMessageInner(
commentaryProgressEnabled: draftPreview.isProgressMode
? draftPreview.commentaryProgressEnabled
: undefined,
commentaryPayloadsEnabled: draftPreview.isProgressMode
? draftPreview.commentaryProgressEnabled
: undefined,
reasoningPayloadsEnabled: reasoningDurableEnabled,
onVerboseProgressVisibility: (isActive) => {
verboseProgressActive = isActive;
shouldYieldDraftProgress = isActive;
},
onReasoningStream: async (payload) => {
if (payload?.requiresReasoningProgressOptIn === true && !reasoningWindowEnabled) {
return;
}
if (payload?.text) {
windowReasoningOpen = true;
}
await statusReactions.setThinking();
await draftPreview.pushReasoningProgress(payload?.text, {
snapshot: payload?.isReasoningSnapshot === true,
});
},
streamReasoningInNonStreamModes: reasoningWindowEnabled,
onToolStart: async (payload) => {
if (isProcessAborted(abortSignal)) {
return;
}
await maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
if (payload.phase === "start") {
closePendingWindowThought();
}
if (shouldYieldDraftProgress()) {
return;
}
// Match the compositor: message/react/typing are not work-tool lines.
if (payload.phase === "start" && isChannelProgressDraftWorkToolName(payload.name)) {
progressToolCalls += 1;
}
await draftPreview.pushToolProgress(
buildChannelProgressDraftLineForEntry(
discordConfig,
@@ -1028,16 +1248,21 @@ async function processDiscordMessageInner(
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
if (verboseProgressActive()) {
if (shouldYieldDraftProgress()) {
return;
}
if (draftPreview.commentaryProgressEnabled && payload.progressText) {
// Count only commentary that actually streams to the window draft.
noteWindowCommentary(payload.itemId, payload.progressText);
await draftPreview.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,
});
}
return;
}
if (shouldYieldDraftProgress()) {
return;
}
await draftPreview.pushToolProgress(
buildChannelProgressDraftLineForEntry(discordConfig, {
event: "item",
@@ -1087,6 +1312,9 @@ async function processDiscordMessageInner(
if (payload.phase !== "end") {
return;
}
if (shouldYieldDraftProgress()) {
return;
}
await draftPreview.pushToolProgress(
buildChannelProgressDraftLine({
event: "command-output",
@@ -1104,6 +1332,9 @@ async function processDiscordMessageInner(
if (payload.phase !== "end") {
return;
}
if (shouldYieldDraftProgress()) {
return;
}
await draftPreview.pushToolProgress(
buildChannelProgressDraftLine({
event: "patch",
@@ -140,4 +140,33 @@ describe("MatrixAuthedHttpClient", () => {
expect(httpError.message).toBe("forbidden");
expect(httpError.statusCode).toBe(403);
});
it("throws descriptive error on malformed JSON success response", async () => {
performMatrixRequestMock.mockResolvedValue({
response: new Response("NOT JSON {{{", {
status: 200,
headers: { "content-type": "application/json" },
}),
text: "NOT JSON {{{",
buffer: Buffer.from("NOT JSON {{{", "utf8"),
});
const client = new MatrixAuthedHttpClient({
homeserver: "https://matrix.example.org",
accessToken: "token",
});
let rejection: unknown;
try {
await client.requestJson({
method: "GET",
endpoint: "/_matrix/client/v3/sync",
timeoutMs: 5000,
});
} catch (error) {
rejection = error;
}
expect(rejection).toBeInstanceOf(Error);
expect((rejection as Error).message).toContain("malformed JSON");
});
});
@@ -52,7 +52,14 @@ export class MatrixAuthedHttpClient {
if (!text.trim()) {
return {};
}
return JSON.parse(text);
try {
return JSON.parse(text);
} catch {
throw Object.assign(
new Error("Matrix homeserver returned malformed JSON"),
{ statusCode: response.status },
);
}
}
return text;
}
+27
View File
@@ -554,6 +554,33 @@ describe("Twilio SMS helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects malformed JSON from Twilio Messaging Service lookup", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response("NOT JSON {{{", { status: 200, headers: { "content-type": "application/json" } }),
);
await expect(
retrieveTwilioMessagingService({
account: createAccount({ messagingServiceSid: "MG123", fromNumber: "" }),
serviceSid: "MG123",
fetchImpl,
}),
).rejects.toThrow("Twilio Messaging Service lookup returned malformed JSON.");
});
it("returns empty list on malformed JSON from Twilio incoming phone number list", async () => {
const fetchImpl = vi.fn<typeof fetch>(
async () => new Response("NOT JSON {{{", { status: 200, headers: { "content-type": "application/json" } }),
);
const result = await listTwilioIncomingPhoneNumbers({
account: createAccount(),
fetchImpl,
});
expect(result).toEqual([]);
});
it("bounds and cancels oversized guarded Twilio success bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedTextResponse("x".repeat(1024 * 1024 + 1), { status: 201 });
+12 -2
View File
@@ -424,7 +424,12 @@ function parseTwilioListPayload<T>(
if (!text.trim()) {
return [];
}
const parsed: unknown = JSON.parse(text);
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return [];
}
if (!parsed || typeof parsed !== "object") {
return [];
}
@@ -482,7 +487,12 @@ export async function retrieveTwilioMessagingService(params: {
if (!response.ok) {
throw new TwilioSmsApiError(response.status, response.text, "messaging-service lookup");
}
const parsed: unknown = JSON.parse(response.text);
let parsed: unknown;
try {
parsed = JSON.parse(response.text);
} catch {
throw new Error("Twilio Messaging Service lookup returned malformed JSON.");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Twilio Messaging Service lookup returned malformed JSON.");
}
@@ -3259,6 +3259,36 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.flush).toHaveBeenCalled();
});
it("keeps streamed reasoning visible when tool progress lines are hidden", async () => {
const draftStream = createSequencedDraftStream(2001);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onReplyStart?.();
await replyOptions?.onAssistantMessageStart?.();
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await replyOptions?.onReasoningStream?.({ text: "<think>Checking files</think>" });
return { queuedFinal: false };
});
await dispatchWithContext({
context: createReasoningStreamContext(),
streamMode: "progress",
telegramCfg: {
streaming: {
mode: "progress",
progress: { label: "Shelling", toolProgress: false },
},
},
});
expect(draftStream.updatePreview).toHaveBeenCalledWith(
telegramProgressPreview(
"Shelling\n\n• Checking files",
"<b>Shelling</b>\n<i>Checking files</i>",
),
);
});
it.each([{ label: false }, { label: "Shelling", maxLines: 1 }] as const)(
"does not duplicate Telegram progress HTML rows without a visible label",
async (progress) => {
@@ -1046,6 +1046,7 @@ export const dispatchTelegramMessage = async ({
active: Boolean(answerLane.stream),
seed: progressSeed,
formatLine: formatTelegramProgressLine,
reasoningGate: streamReasoningInProgressDraft,
update: async (streamText, options) => {
await prepareAnswerLaneForToolProgress();
answerLane.lastPartialText = streamText;
@@ -122,7 +122,7 @@ flow:
message:
expr: "`missing off marker; saw ${offMessages.map((message) => message.text).join(' | ')}`"
- assert:
expr: "!offMessages.some((candidate) => candidate.text.trimStart().startsWith('Reasoning:') && !candidate.text.includes('Native reasoning was produced; no summary text was returned.'))"
expr: "!offMessages.some((candidate) => candidate.text.trimStart().startsWith('Reasoning:'))"
message:
expr: "`disabled thinking unexpectedly emitted reasoning: ${offMessages.map((message) => message.text).join(' | ')}`"
- if:
+7 -7
View File
@@ -112,11 +112,11 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"runtime-logger": 3,
"runtime-secret-resolution": 5,
"setup-adapter-runtime": 1,
"channel-streaming": 47,
"channel-streaming": 48,
"approval-reply-runtime": 1,
"config-runtime": 123,
"config-contracts": 1,
"config-types": 420,
"config-types": 421,
"config-schema": 3,
"reply-dedupe": 1,
"inbound-reply-dispatch": 33,
@@ -158,8 +158,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"channel-mention-gating": 7,
"channel-lifecycle": 23,
"channel-ingress": 8,
"channel-message": 228,
"channel-message-runtime": 225,
"channel-message": 229,
"channel-message-runtime": 226,
"channel-pairing-paths": 1,
"channel-policy": 8,
"channel-route": 5,
@@ -202,11 +202,11 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10400),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5219),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10401),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5220),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3256,
3260,
),
publicWildcardReexports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",
+181 -7
View File
@@ -200,6 +200,64 @@ describe("anthropic transport stream", () => {
vi.useRealTimers();
});
it("tags pre-tool narration as commentary when a proxy mislabels stop_reason (pioneer/Bedrock)", async () => {
// Bedrock/Vertex-proxied routes (e.g. pioneer; tool ids "toolu_vrtx_…") report
// stop_reason "end_turn" on turns that DO carry a tool call. Commentary tagging
// must key on the turn CONTAINING a toolCall, not on the stop_reason label, or
// the narration text stays untagged (textSignature=None) and never reaches the
// 💬 lane — exactly the pioneer commentary gap.
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_pio", usage: { input_tokens: 10, output_tokens: 0 } },
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "I'll start by checking the current date." },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "toolu_vrtx_01S4", name: "exec", input: {} },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"command":"date"}' },
},
{ type: "content_block_stop", index: 1 },
{
// The proxy mislabel: a tool-using turn reported as end_turn, NOT tool_use.
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 10, output_tokens: 7 },
},
]),
);
const result = await runTransportStream(
makeAnthropicTransportModel(),
{
messages: [{ role: "user", content: "run date" }],
} as AnthropicStreamContext,
{
apiKey: "sk-ant-api",
} as AnthropicStreamOptions,
);
// Despite stop_reason=end_turn, the turn carries a toolCall, so the narration
// text must be tagged commentary (phase:commentary) and route to 💬.
const textBlock = findRecord(result.content, (record) => record.type === "text");
expect(textBlock.textSignature).toBeDefined();
expect(String(textBlock.textSignature)).toContain('"phase":"commentary"');
expect(result.content.some((block) => (block as { type?: string }).type === "toolCall")).toBe(
true,
);
});
it("uses the guarded fetch transport for api-key Anthropic requests", async () => {
const model = makeAnthropicTransportModel({
headers: { "X-Provider": "anthropic" },
@@ -787,6 +845,122 @@ describe("anthropic transport stream", () => {
expect(result.errorMessage).toBe("Anthropic stream ended before message_stop");
});
it("defers a pre-tool text block's text_end until it carries the commentary phase", async () => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_defer", usage: { input_tokens: 5, output_tokens: 0 } },
},
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "I'll check the repo." },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "tool_1", name: "exec", input: {} },
},
{ type: "content_block_stop", index: 1 },
{
type: "message_delta",
delta: { stop_reason: "tool_use" },
usage: { input_tokens: 5, output_tokens: 7 },
},
{ type: "message_stop" },
]),
);
const streamFn = createAnthropicMessagesTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
makeAnthropicTransportModel(),
{ messages: [{ role: "user", content: "inspect" }] } as AnthropicStreamContext,
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
),
);
const order: string[] = [];
let textEndPhase: unknown;
for await (const event of stream as AsyncIterable<{
type: string;
contentIndex?: number;
partial?: { content?: Array<{ textSignature?: string }> };
}>) {
order.push(event.type);
if (event.type === "text_end" && typeof event.contentIndex === "number") {
const signature = event.partial?.content?.[event.contentIndex]?.textSignature;
textEndPhase =
typeof signature === "string"
? (JSON.parse(signature) as { phase?: string }).phase
: undefined;
}
}
// The pre-tool text block's text_end is held until the tool boundary tags it
// commentary, so a block-reply consumer never durably commits the narration
// as the answer. It is still emitted (once) and still before the tool call.
expect(textEndPhase).toBe("commentary");
expect(order.filter((type) => type === "text_end")).toHaveLength(1);
expect(order.indexOf("text_end")).toBeLessThan(order.indexOf("toolcall_start"));
});
it("emits a non-tool text block's text_end as unphased answer text", async () => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_answer", usage: { input_tokens: 5, output_tokens: 0 } },
},
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Here is the answer." },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: { input_tokens: 5, output_tokens: 4 },
},
{ type: "message_stop" },
]),
);
const streamFn = createAnthropicMessagesTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
makeAnthropicTransportModel(),
{ messages: [{ role: "user", content: "answer me" }] } as AnthropicStreamContext,
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
),
);
const order: string[] = [];
let textEndPhase: unknown = "unset";
for await (const event of stream as AsyncIterable<{
type: string;
contentIndex?: number;
partial?: { content?: Array<{ textSignature?: string }> };
}>) {
order.push(event.type);
if (event.type === "text_end" && typeof event.contentIndex === "number") {
const signature = event.partial?.content?.[event.contentIndex]?.textSignature;
textEndPhase =
typeof signature === "string"
? (JSON.parse(signature) as { phase?: string }).phase
: undefined;
}
}
const result = await stream.result();
// No tool follows, so the held text_end is flushed unphased at message_delta
// and the text is delivered as the answer (never tagged commentary).
expect(order.filter((type) => type === "text_end")).toHaveLength(1);
expect(textEndPhase).toBeUndefined();
const textBlock = findRecord(result.content, (record) => record.type === "text");
expect(textBlock.text).toBe("Here is the answer.");
expect(textBlock.textSignature).toBeUndefined();
});
it("preserves unsafe integer Anthropic tool-use input deltas", async () => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
@@ -2479,7 +2653,7 @@ describe("anthropic transport stream", () => {
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
expect(payload.tool_choice).toBeUndefined();
});
@@ -2556,7 +2730,7 @@ describe("anthropic transport stream", () => {
const payload = latestAnthropicRequest().payload;
expect(payload.model).toBe("production-claude");
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
expect(payload).not.toHaveProperty("temperature");
});
@@ -2659,7 +2833,7 @@ describe("anthropic transport stream", () => {
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
});
@@ -2686,7 +2860,7 @@ describe("anthropic transport stream", () => {
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
});
@@ -2791,7 +2965,7 @@ describe("anthropic transport stream", () => {
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "xhigh" });
});
@@ -2815,7 +2989,7 @@ describe("anthropic transport stream", () => {
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "max" });
});
@@ -2840,7 +3014,7 @@ describe("anthropic transport stream", () => {
);
const payload = latestAnthropicRequest().payload;
expect(payload.thinking).toEqual({ type: "adaptive" });
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
expect(payload.output_config).toEqual({ effort: "high" });
});
+62 -6
View File
@@ -12,7 +12,7 @@ import {
ANTHROPIC_OMITTED_REASONING_TEXT,
findActiveAnthropicToolTurnAssistantIndex,
} from "../llm/providers/anthropic-thinking-replay.js";
import type { AnthropicOptions } from "../llm/providers/anthropic.js";
import type { AnthropicOptions, AnthropicThinkingDisplay } from "../llm/providers/anthropic.js";
import type {
AssistantMessageDiagnostic,
Context,
@@ -58,6 +58,7 @@ import {
coerceTransportToolCallArguments,
createEmptyTransportUsage,
createWritableTransportEventStream,
encodeAssistantTextSignatureV1,
failTransportStream,
finalizeTransportStream,
mergeTransportHeaders,
@@ -116,7 +117,7 @@ function resolveAnthropicRequestModelId(model: AnthropicTransportModel): string
}
type TransportContentBlock =
| { type: "text"; text: string; index?: number }
| { type: "text"; text: string; index?: number; textSignature?: string }
| {
type: "thinking";
thinking: string;
@@ -581,6 +582,25 @@ function mapStopReason(reason: string | undefined): string {
}
}
function tagPendingCommentaryText(content: TransportContentBlock[]): void {
let commentaryTextIndex = content.filter(
(block) => block.type === "text" && block.textSignature !== undefined,
).length;
for (const block of content) {
if (
block.type === "text" &&
block.text.trim().length > 0 &&
block.textSignature === undefined
) {
block.textSignature = encodeAssistantTextSignatureV1(
`commentary-${commentaryTextIndex}`,
"commentary",
);
commentaryTextIndex += 1;
}
}
}
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
/** Resolve the effective Anthropic API base URL from model or environment. */
@@ -975,9 +995,13 @@ function buildAnthropicParams(
if (fable5 || model.reasoning || supportsAdaptiveThinking(model)) {
if (fable5 || options?.thinkingEnabled) {
if (supportsAdaptiveThinking(model)) {
params.thinking = fable5
? { type: "adaptive", display: "summarized" }
: { type: "adaptive" };
// Default display to "summarized" so Opus 4.7+/Fable 5 return a thinking
// summary like older Claude 4 models — mirrors the provider path
// (llm/providers/anthropic.ts). Without it the adaptive request omits the
// summary and only an encrypted signature comes back, so the 🧠 lane is
// blank (the live agent transport previously sent this for opus-4-8).
const display: AnthropicThinkingDisplay = options?.thinkingDisplay ?? "summarized";
params.thinking = { type: "adaptive", display };
const effort = options?.effort ?? (fable5 ? "high" : undefined);
if (effort) {
params.output_config = { effort };
@@ -1122,6 +1146,14 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
const reasoningContentThinkingBlocks = new Map<number, number>();
const reasoningContentTextBlocks = new Map<number, number>();
let sawMessageStop = false;
const pendingTextEnds: Array<Parameters<typeof eventSink.push>[0]> = [];
// Hold text_end until tool-boundary classification is known.
const flushPendingTextEnds = () => {
for (const event of pendingTextEnds) {
eventSink.push(event);
}
pendingTextEnds.length = 0;
};
const eventIndexKey = (eventIndex: unknown) =>
typeof eventIndex === "number" ? eventIndex : -1;
const appendReasoningContentThinkingDelta = (
@@ -1339,6 +1371,8 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
continue;
}
if (contentBlock?.type === "tool_use") {
tagPendingCommentaryText(output.content);
flushPendingTextEnds();
const block: TransportContentBlock = {
type: "toolCall",
id: typeof contentBlock.id === "string" ? contentBlock.id : "",
@@ -1489,7 +1523,7 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
blockIndexes.delete(eventIndex);
delete block.index;
if (block.type === "text") {
eventSink.push({
pendingTextEnds.push({
type: "text_end",
contentIndex: index,
content: block.text,
@@ -1553,6 +1587,16 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
output.usage.cacheRead +
output.usage.cacheWrite;
calculateCost(model, output.usage);
// Gate on the turn CONTAINING a tool call, not the provider's stop_reason
// label: Bedrock/Vertex-proxied routes (e.g. pioneer) report "end_turn" on
// tool-using turns. No-op for direct Anthropic (already "toolUse" here).
if (
output.stopReason === "toolUse" ||
output.content.some((block) => block.type === "toolCall")
) {
tagPendingCommentaryText(output.content);
}
flushPendingTextEnds();
}
}
if (refusalBuffer && !sawMessageStop) {
@@ -1565,6 +1609,18 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
refusalBuffer?.flush();
// Backstop: streaming tags commentary at the tool-boundary above, but
// replay/non-streaming assembly may reach here with tool calls untagged.
// Idempotent, so it never double-tags the streaming path. Gate on the turn
// containing a tool call (not stop_reason) so proxied Bedrock/Vertex routes
// that mislabel tool turns as "end_turn" still tag their narration.
if (
output.stopReason === "toolUse" ||
output.content.some((block) => block.type === "toolCall")
) {
tagPendingCommentaryText(output.content);
}
flushPendingTextEnds();
finalizeTransportStream({ stream, output });
} catch (error) {
if (refusalBuffer) {
+2
View File
@@ -9,6 +9,8 @@ export type BlockReplyPayload = {
trustedLocalMedia?: boolean;
sensitiveMedia?: boolean;
isReasoning?: boolean;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
@@ -19,6 +19,8 @@ type AgentPayloadLike = {
attachments?: unknown;
isError?: unknown;
isReasoning?: unknown;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: unknown;
};
type AgentDeliveryEvidence = {
+1
View File
@@ -2225,6 +2225,7 @@ async function runEmbeddedAgentInternal(
blockReplyBreak: params.blockReplyBreak,
blockReplyChunking: params.blockReplyChunking,
onReasoningStream: params.onReasoningStream,
streamReasoningInNonStreamModes: params.streamReasoningInNonStreamModes,
onReasoningEnd: params.onReasoningEnd,
onToolResult: notifyToolResult,
onAgentToolResult: params.onAgentToolResult,
@@ -3581,6 +3581,7 @@ export async function runEmbeddedAttempt(
onAgentToolResult: params.onAgentToolResult,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream,
streamReasoningInNonStreamModes: params.streamReasoningInNonStreamModes,
onReasoningEnd: params.onReasoningEnd,
onBlockReply,
onBlockReplyFlush,
@@ -39,6 +39,13 @@ export type { ClientToolDefinition } from "../../command/shared-types.js";
export type EmbeddedRunTrigger = "cron" | "heartbeat" | "manual" | "memory" | "overflow" | "user";
type ReasoningStreamPayload = Pick<
ReplyPayload,
"text" | "mediaUrls" | "isReasoning" | "isReasoningSnapshot"
> & {
requiresReasoningProgressOptIn?: boolean;
};
export type CurrentInboundPromptContext = {
text: string;
resumableText?: string;
@@ -227,11 +234,8 @@ export type RunEmbeddedAgentParams = {
onBlockReplyFlush?: () => void | Promise<void>;
blockReplyBreak?: "text_end" | "message_end";
blockReplyChunking?: BlockReplyChunking;
onReasoningStream?: (payload: {
text?: string;
mediaUrls?: string[];
isReasoningSnapshot?: boolean;
}) => void | Promise<void>;
onReasoningStream?: (payload: ReasoningStreamPayload) => void | Promise<void>;
streamReasoningInNonStreamModes?: boolean;
onReasoningEnd?: () => void | Promise<void>;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
/** Synchronous private observer for the sanitized per-tool result. */
@@ -590,6 +590,8 @@ export function buildEmbeddedRunPayloads(params: {
mediaUrl?: string;
isError?: boolean;
isReasoning?: boolean;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: boolean;
audioAsVoice?: boolean;
replyToId?: string;
replyToTag?: boolean;
@@ -174,7 +174,16 @@ export function resolveEmbeddedAgentStreamFn(params: {
if (
isDefaultOpenClawStreamFnForModel(params.model, params.currentStreamFn) ||
hasResolvedRuntimeApiKey(params.resolvedApiKey)
hasResolvedRuntimeApiKey(params.resolvedApiKey) ||
// Proxied anthropic-messages providers (provider !== "anthropic", e.g. pioneer)
// must use the boundary-aware managed transport even without a resolved runtime
// key — it is the only place a tool-using turn's narration gets tagged
// phase:commentary; the base SDK stream never tags it, so proxied anthropic
// providers silently lost their narration lane. Scoped to non-"anthropic"
// providers so direct-anthropic edge cases (thinking-replay repair without a
// resolved key) are unchanged; the wrap below injects the resolved key
// (fallback options.apiKey), preserving x-api-key auth.
(params.model.api === "anthropic-messages" && params.model.provider !== "anthropic")
) {
const boundaryAwareStreamFn = createBoundaryAwareStreamFnForModel(params.model);
if (boundaryAwareStreamFn) {
@@ -193,6 +193,8 @@ export type EmbeddedAgentRunResult = {
replyToId?: string;
isError?: boolean;
isReasoning?: boolean;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: boolean;
audioAsVoice?: boolean;
trustedLocalMedia?: boolean;
channelData?: Record<string, unknown>;
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest";
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
import {
buildAssistantStreamData,
consumePendingAssistantReplyDirectivesIntoReply,
consumePendingToolMediaIntoReply,
consumePendingToolMediaReply,
@@ -11,6 +12,8 @@ import {
handleMessageUpdate,
hasAssistantVisibleReply,
readPendingToolMediaReply,
recordPendingAssistantReplyDirectives,
resolveSilentReplyFallbackText,
} from "./embedded-agent-subscribe.handlers.messages.js";
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
import {
@@ -199,6 +202,50 @@ function createMessageToolEnvelope(message: string, args: Record<string, unknown
});
}
describe("resolveSilentReplyFallbackText", () => {
it("replaces NO_REPLY with latest messaging tool text when available", () => {
expect(
resolveSilentReplyFallbackText({
text: "NO_REPLY",
messagingToolSentTexts: ["first", "final delivered text"],
}),
).toBe("final delivered text");
});
it("keeps original text when response is not NO_REPLY", () => {
expect(
resolveSilentReplyFallbackText({
text: "normal assistant reply",
messagingToolSentTexts: ["final delivered text"],
}),
).toBe("normal assistant reply");
});
it("keeps NO_REPLY when there is no messaging tool text to mirror", () => {
expect(
resolveSilentReplyFallbackText({
text: "NO_REPLY",
messagingToolSentTexts: [],
}),
).toBe("NO_REPLY");
});
it("tolerates malformed text payloads without throwing", () => {
expect(
resolveSilentReplyFallbackText({
text: undefined,
messagingToolSentTexts: ["final delivered text"],
}),
).toBe("");
expect(
resolveSilentReplyFallbackText({
text: "NO_REPLY",
messagingToolSentTexts: [42 as unknown as string],
}),
).toBe("42");
});
});
describe("hasAssistantVisibleReply", () => {
it("treats audio-only payloads as visible", () => {
expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true);
@@ -211,16 +258,38 @@ describe("hasAssistantVisibleReply", () => {
});
});
describe("buildAssistantStreamData", () => {
it("normalizes media payloads for assistant stream events", () => {
expect(
buildAssistantStreamData({
text: "hello",
delta: "he",
replace: true,
mediaUrl: "https://example.com/a.png",
phase: "final_answer",
}),
).toEqual({
text: "hello",
delta: "he",
replace: true,
mediaUrls: ["https://example.com/a.png"],
phase: "final_answer",
});
});
});
describe("pending assistant reply directives", () => {
it("merges directive metadata into the next non-reasoning block reply", () => {
const state = {
pendingAssistantReplyDirectives: {
mediaUrls: ["/tmp/reply.ogg"],
replyToCurrent: true,
replyToTag: true,
audioAsVoice: true,
},
};
const state = { pendingAssistantReplyDirectives: undefined };
recordPendingAssistantReplyDirectives(state, {
text: "",
mediaUrls: ["/tmp/reply.ogg"],
replyToCurrent: true,
replyToTag: true,
audioAsVoice: true,
isSilent: false,
});
expect(
consumePendingAssistantReplyDirectivesIntoReply(state, {
@@ -405,6 +474,12 @@ describe("handleMessageUpdate text signatures", () => {
);
expect(onAgentEvent.mock.calls.map(([event]) => event)).toMatchObject([
// Emit-always: the commentary delta reaches the bus tagged with its
// phase; reply lanes still exclude it (covered below).
{
stream: "assistant",
data: { delta: "Hello", phase: "commentary", itemId: "item-commentary" },
},
{
stream: "assistant",
data: { text: "Hello world", delta: "Hello world", phase: "final_answer" },
@@ -412,6 +487,54 @@ describe("handleMessageUpdate text signatures", () => {
]);
});
it("emits a commentary snapshot when Anthropic text is classified after deltas", () => {
const onAgentEvent = vi.fn();
const context = createMessageUpdateContext({ onAgentEvent });
const narration = "I'll check the repo first.";
const commentaryPartial = {
role: "assistant",
api: "anthropic-messages",
content: [
{
type: "text",
text: narration,
textSignature: JSON.stringify({ v: 1, id: "commentary-0", phase: "commentary" }),
},
],
};
handleMessageUpdate(context, {
type: "message_update",
message: {
role: "assistant",
api: "anthropic-messages",
content: [{ type: "text", text: narration }],
},
assistantMessageEvent: { type: "text_delta", delta: narration },
} as never);
handleMessageUpdate(context, {
type: "message_update",
message: { role: "assistant", api: "anthropic-messages", content: [] },
assistantMessageEvent: {
type: "text_end",
content: narration,
partial: commentaryPartial,
},
} as never);
expect(onAgentEvent.mock.calls.map(([event]) => event)).toContainEqual(
expect.objectContaining({
stream: "assistant",
data: expect.objectContaining({
text: narration,
replace: true,
phase: "commentary",
itemId: "commentary-0",
}),
}),
);
});
it("uses incremental deltas for same-item phased streams", () => {
const onAgentEvent = vi.fn();
const context = createMessageUpdateContext({ onAgentEvent });
@@ -821,14 +944,16 @@ describe("handleMessageUpdate commentary phase", () => {
await Promise.resolve();
expect(onAgentEvent).not.toHaveBeenCalled();
// Archive-always: commentary (textSignature-only phase — the F3 shape) is
// emitted on the bus for archival + window, but kept out of the reply lanes.
expect(onAgentEvent).toHaveBeenCalled();
expect(onPartialReply).not.toHaveBeenCalled();
expect(flushBlockReplyBuffer).not.toHaveBeenCalled();
expect(ctx.state.deltaBuffer).toBe("");
expect(ctx.state.blockBuffer).toBe("");
});
it("suppresses commentary partials even when they contain visible text", () => {
it("keeps commentary partials out of reply lanes while emitting them on the bus", () => {
const onAgentEvent = vi.fn();
const ctx = createMessageUpdateContext({
onAgentEvent,
@@ -849,7 +974,15 @@ describe("handleMessageUpdate commentary phase", () => {
}),
);
expect(onAgentEvent).not.toHaveBeenCalled();
// Emit-always: the bus sees the commentary delta with its phase tag, but
// reply-text buffers stay untouched.
expect(onAgentEvent).toHaveBeenCalledTimes(1);
const commentaryEvent = firstMockArg(onAgentEvent, "agent event") as
| { stream?: string; data?: { delta?: string; phase?: string } }
| undefined;
expect(commentaryEvent?.stream).toBe("assistant");
expect(commentaryEvent?.data?.phase).toBe("commentary");
expect(commentaryEvent?.data?.delta).toBe("Working...");
expect(ctx.state.deltaBuffer).toBe("");
expect(ctx.state.blockBuffer).toBe("");
@@ -867,8 +1000,8 @@ describe("handleMessageUpdate commentary phase", () => {
}),
);
expect(onAgentEvent).toHaveBeenCalledTimes(1);
const event = firstMockArg(onAgentEvent, "agent event") as
expect(onAgentEvent).toHaveBeenCalledTimes(2);
const event = onAgentEvent.mock.calls[1]?.[0] as
| { stream?: string; data?: { text?: string; delta?: string } }
| undefined;
expect(event?.stream).toBe("assistant");
@@ -1014,7 +1147,8 @@ describe("handleMessageEnd", () => {
},
} as never);
expect(onAgentEvent).not.toHaveBeenCalled();
// Archive-always: commentary reaches the bus/archive but not the visible reply.
expect(onAgentEvent).toHaveBeenCalled();
expect(emitBlockReply).not.toHaveBeenCalled();
expect(finalizeAssistantTexts).not.toHaveBeenCalled();
});
@@ -1044,7 +1178,9 @@ describe("handleMessageEnd", () => {
},
} as never);
expect(onAgentEvent).not.toHaveBeenCalled();
// Archive-always: commentary (textSignature-only phase) reaches the
// bus/archive but not the visible reply.
expect(onAgentEvent).toHaveBeenCalled();
expect(emitBlockReply).not.toHaveBeenCalled();
expect(finalizeAssistantTexts).not.toHaveBeenCalled();
});
@@ -34,6 +34,7 @@ import { warnIfAssistantEmittedToolText } from "./embedded-agent-subscribe.tool-
import {
extractAssistantText,
extractAssistantThinking,
extractAssistantCommentaryText,
extractAssistantVisibleText,
extractThinkingFromTaggedStream,
extractThinkingFromTaggedText,
@@ -63,6 +64,14 @@ function isOpenAiResponsesAssistantMessage(message: AgentMessage | undefined): b
return api === "openai-responses" || api === "azure-openai-responses";
}
function isAnthropicAssistantMessage(message: AgentMessage | undefined): boolean {
if (!message || message.role !== "assistant") {
return false;
}
const api = normalizeOptionalString((message as { api?: unknown }).api) ?? "";
return api === "anthropic-messages";
}
function isOpenAiCompletionsAssistantMessage(message: AgentMessage | undefined): boolean {
if (!message || message.role !== "assistant") {
return false;
@@ -271,7 +280,7 @@ function copyPartialBlockState(
}
/** Replaces a silent-reply token with the latest sent messaging-tool text when available. */
function resolveSilentReplyFallbackText(params: {
export function resolveSilentReplyFallbackText(params: {
text: unknown;
messagingToolSentTexts: string[];
}): string {
@@ -475,7 +484,7 @@ function resolveStreamingReplyText(params: {
}
/** Records parsed reply directives until a sendable reply payload is built. */
function recordPendingAssistantReplyDirectives(
export function recordPendingAssistantReplyDirectives(
state: Pick<EmbeddedAgentSubscribeState, "pendingAssistantReplyDirectives">,
parsed: ReplyDirectiveParseResult | null | undefined,
) {
@@ -529,19 +538,21 @@ export function hasAssistantVisibleReply(params: {
}
/** Builds normalized stream payload data for assistant visible output. */
function buildAssistantStreamData(params: {
export function buildAssistantStreamData(params: {
text?: string;
delta?: string;
replace?: boolean;
mediaUrls?: string[];
mediaUrl?: string;
phase?: AssistantPhase;
itemId?: string;
}): {
text: string;
delta: string;
replace?: true;
mediaUrls?: string[];
phase?: AssistantPhase;
itemId?: string;
} {
const mediaUrls = resolveSendableOutboundReplyParts(params).mediaUrls;
return {
@@ -550,6 +561,7 @@ function buildAssistantStreamData(params: {
replace: params.replace ? true : undefined,
mediaUrls: mediaUrls.length ? mediaUrls : undefined,
phase: params.phase,
itemId: params.itemId,
};
}
@@ -586,6 +598,21 @@ export function handleMessageUpdate(
ctx.noteLastAssistant(msg);
const suppressVisibleAssistantOutput = shouldSuppressAssistantVisibleOutput(msg);
if (suppressVisibleAssistantOutput) {
const commentaryText = coerceChatContentText(extractAssistantCommentaryText(msg));
if (commentaryText) {
appendRawStream({
ts: Date.now(),
event: "assistant_text_stream",
runId: ctx.params.runId,
sessionId: (ctx.params.session as { id?: string }).id,
evtType: "commentary_update",
delta: "",
content: commentaryText,
});
ctx.emitAssistantStreamData(
buildAssistantStreamData({ text: commentaryText, replace: true, phase: "commentary" }),
);
}
return;
}
const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state);
@@ -625,12 +652,18 @@ export function handleMessageUpdate(
delta: thinkingDelta,
content: thinkingContent,
});
if (!suppressMessageToolOnlySourceReplyOutput && ctx.state.streamReasoning) {
// Prefer full partial-message thinking when available; fall back to event payloads.
const partialThinking = extractAssistantThinking(msg);
ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta);
}
if (!suppressMessageToolOnlySourceReplyOutput && evtType === "thinking_end") {
// Emit-always: emitReasoningStream always reaches the bus/archive; the
// streamReasoning rendering hook and message_tool_only source suppression
// are gated downstream (dispatch wrapProgressCallback, #92738), so emission
// here stays unconditional.
// Prefer full partial-message thinking when available; fall back to event payloads.
const partialThinking = extractAssistantThinking(msg);
ctx.emitReasoningStream(partialThinking || thinkingContent || thinkingDelta);
if (evtType === "thinking_end" && !suppressMessageToolOnlySourceReplyOutput) {
// Mirror the open gate above: when message-tool-only delivery has made the
// reasoning lane private, do not force-open it just to close it — that
// would fire the lane's end hook (onReasoningEnd) for a lane that never
// rendered, leaking the boundary signal.
if (!ctx.state.reasoningStreamOpen) {
openReasoningStream(ctx);
}
@@ -677,6 +710,10 @@ export function handleMessageUpdate(
!deliveryPhase &&
Boolean(streamItemId) &&
isOpenAiResponsesAssistantMessage(partialAssistant);
// Anthropic commentary is known only at the tool boundary; keep early
// unphased deltas out of durable block replies until that phase is known.
const isPhasePendingAnthropicText =
evtType !== "text_end" && !deliveryPhase && isAnthropicAssistantMessage(partialAssistant);
let streamItemChanged = false;
if ((deliveryPhase || isPhasePendingOpenAiResponsesTextItem) && streamItemId) {
const previousStreamItemId = ctx.state.lastAssistantStreamItemId;
@@ -689,6 +726,22 @@ export function handleMessageUpdate(
ctx.state.lastAssistantStreamItemId = streamItemId;
}
if (deliveryPhase === "commentary") {
const commentaryText = chunk
? undefined
: coerceChatContentText(extractAssistantCommentaryText(partialAssistant));
const commentaryData = chunk
? buildAssistantStreamData({ delta: chunk, phase: "commentary", itemId: streamItemId })
: commentaryText
? buildAssistantStreamData({
text: commentaryText,
replace: true,
phase: "commentary",
itemId: streamItemId,
})
: undefined;
if (commentaryData) {
ctx.emitAssistantStreamData(commentaryData);
}
return;
}
if (isPhasePendingOpenAiResponsesTextItem) {
@@ -702,7 +755,7 @@ export function handleMessageUpdate(
if (chunk) {
ctx.state.deltaBuffer += chunk;
if (!skipLiveStream && !shouldUsePhaseAwareBlockReply) {
if (!skipLiveStream && !shouldUsePhaseAwareBlockReply && !isPhasePendingAnthropicText) {
appendBlockReplyChunk(ctx, chunk);
}
}
@@ -711,10 +764,10 @@ export function handleMessageUpdate(
return;
}
if (!suppressMessageToolOnlySourceReplyOutput && ctx.state.streamReasoning) {
// Handle partial <think> tags: stream whatever reasoning is visible so far.
ctx.emitReasoningStream(extractThinkingFromTaggedStream(ctx.state.deltaBuffer));
}
// Handle partial <think> tags: stream whatever reasoning is visible so far.
// Emit-always: emitReasoningStream reaches the bus/archive; rendering +
// message_tool_only suppression are gated downstream (#92738).
ctx.emitReasoningStream(extractThinkingFromTaggedStream(ctx.state.deltaBuffer));
const wasThinking = ctx.state.partialBlockState.thinking;
let visibleDelta = "";
const shouldReadPhaseAwarePartialText =
@@ -920,6 +973,36 @@ export function handleMessageEnd(
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
ctx.commitAssistantUsage();
if (suppressVisibleAssistantOutput) {
const commentaryText = coerceChatContentText(extractAssistantCommentaryText(assistantMessage));
appendRawStream({
ts: Date.now(),
event: "assistant_message_end",
runId: ctx.params.runId,
sessionId: (ctx.params.session as { id?: string }).id,
rawText: coerceChatContentText(extractAssistantText(assistantMessage)),
rawThinking: extractAssistantThinking(assistantMessage),
});
if (commentaryText) {
ctx.emitAssistantStreamData(
buildAssistantStreamData({ text: commentaryText, replace: true, phase: "commentary" }),
);
}
// Commentary-tagged tool turns can still carry durable reasoning under /reasoning on.
const suppressedTrimmedReasoning = ctx.state.includeReasoning
? extractAssistantThinking(assistantMessage).trim()
: "";
if (
!ctx.params.silentExpected &&
!suppressDeterministicApprovalOutput &&
!suppressMessageToolOnlySourceReplyOutput &&
ctx.state.includeReasoning &&
suppressedTrimmedReasoning &&
ctx.params.onBlockReply &&
suppressedTrimmedReasoning !== ctx.state.lastReasoningSent
) {
ctx.state.lastReasoningSent = suppressedTrimmedReasoning;
ctx.emitBlockReply({ text: suppressedTrimmedReasoning, isReasoning: true });
}
return;
}
promoteThinkingTagsToBlocks(assistantMessage);
@@ -1051,6 +1134,8 @@ export function handleMessageEnd(
return;
}
ctx.state.lastReasoningSent = trimmedReasoning;
// Lane purity: the payload carries raw thinking only. Tool persistence is
// the verbose lane's job; interleaving comes from arrival order.
ctx.emitBlockReply({ text: trimmedReasoning, isReasoning: true });
};
@@ -1174,12 +1259,9 @@ export function handleMessageEnd(
if (!shouldEmitReasoningBeforeAnswer) {
maybeEmitReasoning();
}
if (
!ctx.params.silentExpected &&
!suppressMessageToolOnlySourceReplyOutput &&
ctx.state.streamReasoning &&
rawThinking
) {
if (!ctx.params.silentExpected && rawThinking) {
// Emit-always: bus/archive get message-end thinking regardless of the
// streamReasoning rendering setting (gated inside emitReasoningStream).
ctx.emitReasoningStream(rawThinking);
}
@@ -885,6 +885,70 @@ describe("subscribeEmbeddedAgentSession", () => {
expect(onReasoningEnd).toHaveBeenCalledTimes(1);
});
type ReasoningWindowGateCase = {
label: string;
reasoningMode: "off" | "stream";
streamReasoningInNonStreamModes?: boolean;
expected: boolean;
};
it.each<ReasoningWindowGateCase>([
{
label: "absent opt-in with off reasoning",
reasoningMode: "off",
expected: false,
},
{
label: "false opt-in with off reasoning",
reasoningMode: "off",
streamReasoningInNonStreamModes: false,
expected: false,
},
{
label: "false opt-in with stream reasoning",
reasoningMode: "stream",
streamReasoningInNonStreamModes: false,
expected: true,
},
{
label: "true opt-in with off reasoning",
reasoningMode: "off",
streamReasoningInNonStreamModes: true,
expected: true,
},
])("gates reasoning-window streaming for $label", (params) => {
const onReasoningStream = vi.fn();
const { emit } = createSubscribedHarness({
runId: "run",
reasoningMode: params.reasoningMode,
...(params.streamReasoningInNonStreamModes === undefined
? {}
: { streamReasoningInNonStreamModes: params.streamReasoningInNonStreamModes }),
onReasoningStream,
});
emit({
type: "message_update",
message: {
role: "assistant",
content: [{ type: "thinking", thinking: "Checking files" }],
},
assistantMessageEvent: {
type: "thinking_delta",
delta: "Checking files",
},
});
if (params.expected) {
expect(onReasoningStream).toHaveBeenCalledWith({
text: "Checking files",
...(params.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }),
});
} else {
expect(onReasoningStream).not.toHaveBeenCalled();
}
});
it("extracts correct reasoning delta for incremental stream updates", () => {
const emitAgentEventSpy = vi.spyOn(agentEvents, "emitAgentEvent").mockImplementation(() => {});
const { emit } = createSubscribedHarness({
@@ -0,0 +1,100 @@
// Anthropic tags pre-tool narration commentary only at the tool_use boundary, so
// during the unphased deltas the subscriber must not durably commit the text on
// block-reply (text_end) channels — otherwise the mid-drain or the pre-tool
// flushBlockReplyBuffer posts the narration as an answer. A non-tool answer must
// still be delivered in full.
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import { createStubSessionHarness } from "./embedded-agent-subscribe.e2e-harness.js";
import { subscribeEmbeddedAgentSession } from "./embedded-agent-subscribe.js";
function anthropicAssistant(text: string, extra?: AssistantMessage["content"]): AssistantMessage {
return {
role: "assistant",
api: "anthropic-messages",
content: [{ type: "text", text }, ...(extra ?? [])],
} as unknown as AssistantMessage;
}
function postedBlockReplyText(onBlockReply: ReturnType<typeof vi.fn>): string {
return onBlockReply.mock.calls.map((call) => call[0]?.text ?? "").join(" ");
}
describe("subscribeEmbeddedAgentSession — Anthropic pre-tool narration", () => {
it("withholds pre-tool narration from durable block replies on a text_end channel", () => {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedAgentSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedAgentSession>[0]["session"],
runId: "run-anthropic-withhold",
onBlockReply,
blockReplyBreak: "text_end",
// Tiny chunking would mid-drain the narration during deltas without the gate.
blockReplyChunking: { minChars: 4, maxChars: 200 },
});
const narration = "Let me check the files before I answer. ";
emit({ type: "message_start", message: anthropicAssistant("") });
emit({
type: "message_update",
message: anthropicAssistant(narration),
assistantMessageEvent: { type: "text_delta", delta: narration },
});
// Tool execution flushes the block-reply buffer before running — the leak point.
emit({
type: "tool_execution_start",
toolName: "bash",
toolCallId: "tool-1",
args: { command: "ls" },
});
// The turn resolves as a tool turn: the leading text is commentary.
emit({
type: "message_end",
message: {
role: "assistant",
api: "anthropic-messages",
stopReason: "toolUse",
content: [
{
type: "text",
text: narration,
textSignature: JSON.stringify({ v: 1, id: "commentary-0", phase: "commentary" }),
},
{ type: "toolCall", id: "tool-1", name: "bash", arguments: {} },
],
} as unknown as AssistantMessage,
});
expect(postedBlockReplyText(onBlockReply)).not.toContain("Let me check the files");
});
it("still delivers a non-tool Anthropic answer in full on a text_end channel", async () => {
const { session, emit } = createStubSessionHarness();
const onBlockReply = vi.fn();
subscribeEmbeddedAgentSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedAgentSession>[0]["session"],
runId: "run-anthropic-answer",
onBlockReply,
blockReplyBreak: "text_end",
});
const answer = "Here is the full answer.";
emit({ type: "message_start", message: anthropicAssistant("") });
emit({
type: "message_update",
message: anthropicAssistant(answer),
assistantMessageEvent: { type: "text_delta", delta: answer },
});
emit({
type: "message_update",
message: anthropicAssistant(answer),
assistantMessageEvent: { type: "text_end", contentIndex: 0 },
});
emit({ type: "message_end", message: anthropicAssistant(answer) });
await vi.waitFor(() => {
expect(onBlockReply).toHaveBeenCalled();
});
expect(postedBlockReplyText(onBlockReply)).toContain("Here is the full answer.");
});
});
+18 -8
View File
@@ -181,7 +181,9 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
includeReasoning: reasoningMode === "on" && canShowReasoning,
shouldEmitPartialReplies: !(reasoningMode === "on" && !params.onBlockReply),
streamReasoning:
reasoningMode === "stream" &&
(params.streamReasoningInNonStreamModes === true
? reasoningMode !== "on"
: reasoningMode === "stream") &&
canShowReasoning &&
typeof params.onReasoningStream === "function",
deltaBuffer: "",
@@ -1167,9 +1169,6 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
if (params.silentExpected) {
return;
}
if (!state.streamReasoning || !params.onReasoningStream) {
return;
}
const trimmed = text.trim();
if (!trimmed) {
return;
@@ -1183,7 +1182,10 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
const delta = trimmed.startsWith(prior) ? trimmed.slice(prior.length) : trimmed;
state.lastStreamedReasoning = trimmed;
// Broadcast thinking event to WebSocket clients in real-time
// Emit-always: the thinking stream always reaches the bus and session
// archive. /reasoning (streamReasoning) gates only the rendering hook
// below; display surfaces (TUI showThinking, webchat isReasoning drops)
// gate presentation on their side.
emitAgentEvent({
runId: params.runId,
stream: "thinking",
@@ -1193,9 +1195,17 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
},
});
void params.onReasoningStream({
text: trimmed,
});
// Message-tool-only delivery makes later reasoning private: once the
// user-facing reply has gone out via the message tool, the channel shows
// only what was explicitly sent, so trailing reasoning must stay out of the
// render hook — uniformly, whether the thinking block rode in on a tool call
// or arrived on its own. It still reaches the bus/archive above.
if (state.streamReasoning && !hasMessageToolOnlySourceDelivery() && params.onReasoningStream) {
void params.onReasoningStream({
text: trimmed,
...(state.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }),
});
}
};
const resetForCompactionRetry = () => {
+10 -5
View File
@@ -26,6 +26,13 @@ export type {
ToolResultFormat,
} from "./embedded-agent-subscribe.shared-types.js";
type ReasoningStreamPayload = Pick<
ReplyPayload,
"text" | "mediaUrls" | "isReasoning" | "isReasoningSnapshot"
> & {
requiresReasoningProgressOptIn?: boolean;
};
export type SubscribeEmbeddedAgentSessionParams = {
session: AgentSession;
runId: string;
@@ -47,11 +54,9 @@ export type SubscribeEmbeddedAgentSessionParams = {
hasDeliveredMessageToolOnlySourceReply?: () => boolean;
onToolResult?: (payload: ReplyPayload) => void | Promise<void>;
onAgentToolResult?: (event: { toolName: string; result: unknown; isError: boolean }) => void;
onReasoningStream?: (payload: {
text?: string;
mediaUrls?: string[];
isReasoningSnapshot?: boolean;
}) => void | Promise<void>;
onReasoningStream?: (payload: ReasoningStreamPayload) => void | Promise<void>;
/** Expands window reasoning beyond "stream" mode for callers with their own display gate. */
streamReasoningInNonStreamModes?: boolean;
/** Called when a thinking/reasoning block ends (</think> tag processed). */
onReasoningEnd?: () => void | Promise<void>;
onBlockReply?: (payload: BlockReplyPayload) => void | Promise<void>;
+3 -4
View File
@@ -673,7 +673,7 @@ describe("formatReasoningMessage", () => {
});
describe("extractAssistantThinking", () => {
it("surfaces signed native reasoning even when the provider returns an empty summary", () => {
it("drops signature-only native reasoning blocks so no diagnostic bubble is surfaced", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
@@ -687,9 +687,8 @@ describe("extractAssistantThinking", () => {
timestamp: Date.now(),
});
expect(extractAssistantThinking(msg)).toBe(
"Native reasoning was produced; no summary text was returned.",
);
// Signature-only block (no summary text) yields "" so downstream .filter(Boolean) drops it.
expect(extractAssistantThinking(msg)).toBe("");
});
});
+13 -2
View File
@@ -143,6 +143,11 @@ export function extractAssistantVisibleText(msg: AssistantMessage): string {
return extractAssistantTextForPhase(msg, undefined, { unphasedSignedFinalAnswer: true }).text;
}
/** Extract the commentary/narration text of a commentary-phase assistant message. */
export function extractAssistantCommentaryText(msg: AssistantMessage): string {
return extractAssistantTextForPhase(msg, "commentary").text;
}
/** Extract sanitized assistant text across all text content blocks. */
export function extractAssistantText(msg: AssistantMessage): string {
const extracted =
@@ -158,7 +163,7 @@ export function extractAssistantText(msg: AssistantMessage): string {
return finalizeAssistantExtraction(msg, extracted);
}
/** Extract native thinking block text or a placeholder when only signed reasoning exists. */
/** Extract native thinking block text; signature-only blocks (no summary) surface nothing. */
export function extractAssistantThinking(msg: AssistantMessage): string {
if (!Array.isArray(msg.content)) {
return "";
@@ -174,8 +179,14 @@ export function extractAssistantThinking(msg: AssistantMessage): string {
if (thinking) {
return thinking;
}
// Signature-only thinking blocks carry a valid signature but no summary text
// (e.g. Anthropic display:"omitted" on Opus 4.7+/Fable 5, or OpenAI/codex reasoning
// items with encrypted_content and an empty summary). Surface nothing so the
// .filter(Boolean) below drops the bubble — a diagnostic placeholder is not reasoning
// content and must not be shown on any channel. The signed block stays on the message
// for API replay; this only governs display.
if (typeof record.thinkingSignature === "string" && record.thinkingSignature.trim()) {
return "Native reasoning was produced; no summary text was returned.";
return "";
}
}
return "";
+2
View File
@@ -251,6 +251,8 @@ export type AgentRuntimeReplyPayload = {
};
isError?: boolean;
isReasoning?: boolean;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: boolean;
isReasoningSnapshot?: boolean;
isCompactionNotice?: boolean;
isFallbackNotice?: boolean;
+12
View File
@@ -30,6 +30,18 @@ type TransportOutputShape = {
};
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
/**
* Encodes an assistant text-block phase signature (v1). Channels and the
* embedded handler read this to route commentary/narration out of the final
* reply. Shared so every provider transport tags phases identically.
*/
export function encodeAssistantTextSignatureV1(
id: string,
phase?: "commentary" | "final_answer",
): string {
return JSON.stringify({ v: 1, id, ...(phase ? { phase } : {}) });
}
export function sanitizeTransportPayloadText(text: string): string {
if (typeof text !== "string") {
return "";
+11 -1
View File
@@ -53,6 +53,13 @@ export type PartialReplyPayload = Pick<ReplyPayload, "text" | "mediaUrls"> & {
replace?: true;
};
type ReasoningStreamPayload = Pick<
ReplyPayload,
"text" | "mediaUrls" | "isReasoning" | "isReasoningSnapshot"
> & {
requiresReasoningProgressOptIn?: boolean;
};
/** Reply generation options shared by auto-reply, webchat, channels, and tests. */
export type GetReplyOptions = {
/** Override run id for agent events (defaults to random UUID). */
@@ -118,7 +125,8 @@ export type GetReplyOptions = {
*/
onVerboseProgressVisibility?: (isActive: () => boolean) => void;
onPartialReply?: (payload: PartialReplyPayload) => Promise<void> | void;
onReasoningStream?: (payload: ReplyPayload) => Promise<void> | void;
onReasoningStream?: (payload: ReasoningStreamPayload) => Promise<void> | void;
streamReasoningInNonStreamModes?: boolean;
/** Called when a thinking/reasoning block ends. */
onReasoningEnd?: () => Promise<void> | void;
/** Called when a new assistant message starts (e.g., after tool call or thinking block). */
@@ -157,6 +165,8 @@ export type GetReplyOptions = {
commentaryProgressEnabled?: boolean;
/** Deliver durable reasoning payloads to channels that own a separate reasoning lane. */
reasoningPayloadsEnabled?: boolean;
/** Deliver durable commentary (💬) payloads to channels that own a separate commentary lane. */
commentaryPayloadsEnabled?: boolean;
/** Called when the agent emits a structured plan update. */
onPlanUpdate?: (payload: {
phase?: string;
+2
View File
@@ -48,6 +48,8 @@ export type ReplyPayload = {
/** Marks this payload as a reasoning/thinking block. Channels that do not
* have a dedicated reasoning lane (e.g. WhatsApp, web) should suppress it. */
isReasoning?: boolean;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: boolean;
/** Reasoning stream text is a complete replacement snapshot, not a delta. */
isReasoningSnapshot?: boolean;
/** Marks this payload as a compaction status notice (start/end).
@@ -355,6 +355,12 @@ type EmbeddedAgentParams = {
}) => void;
onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => Promise<void> | void;
onReasoningStream?: (payload: {
text?: string;
mediaUrls?: string[];
isReasoningSnapshot?: boolean;
requiresReasoningProgressOptIn?: boolean;
}) => Promise<void> | void;
onItemEvent?: (payload: {
itemId?: string;
toolCallId?: string;
@@ -3433,6 +3439,38 @@ describe("runAgentTurnWithFallback", () => {
expect(onReasoningStream).not.toHaveBeenCalled();
});
it("preserves embedded reasoning stream opt-in markers", async () => {
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
await params.onReasoningStream?.({ text: "stream thought" });
await params.onReasoningStream?.({
text: "ambient thought",
requiresReasoningProgressOptIn: true,
});
return { payloads: [{ text: "final" }], meta: {} };
});
const onReasoningStream = vi.fn<NonNullable<GetReplyOptions["onReasoningStream"]>>(
async (_payload) => undefined,
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
await runAgentTurnWithFallback(
createMinimalRunAgentTurnParams({
opts: { onReasoningStream },
}),
);
expect(
onReasoningStream.mock.calls.map(([payload]) => ({
text: payload.text,
requiresReasoningProgressOptIn: payload.requiresReasoningProgressOptIn,
})),
).toEqual([
{ text: "stream thought", requiresReasoningProgressOptIn: undefined },
{ text: "ambient thought", requiresReasoningProgressOptIn: true },
]);
});
it("resolves CLI messageProvider from the live session surface when no origin channel is set", async () => {
state.isCliProviderMock.mockReturnValue(true);
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({
+266 -223
View File
@@ -2320,7 +2320,10 @@ export async function runAgentTurnWithFallback(params: {
await params.opts.onPartialReply({ text: textForTyping });
},
onReasoningText: async (text) => {
await params.opts?.onReasoningStream?.({ text });
await params.opts?.onReasoningStream?.({
text,
requiresReasoningProgressOptIn: true,
});
},
onToolEvent: async (payload) => {
await cliToolSummaryTracker.noteToolEvent(payload);
@@ -2621,246 +2624,286 @@ export async function runAgentTurnWithFallback(params: {
text: payload.text,
mediaUrls: payload.mediaUrls,
isReasoningSnapshot: payload.isReasoningSnapshot,
requiresReasoningProgressOptIn:
payload.requiresReasoningProgressOptIn,
});
}
: undefined,
streamReasoningInNonStreamModes: params.opts?.streamReasoningInNonStreamModes,
onReasoningEnd: params.opts?.onReasoningEnd,
onAgentEvent: async (evt) => {
lifecycleBackstop.note(evt);
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data.phase === "string";
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
notifyAgentRunStart();
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = readStringValue(evt.data.phase) ?? "";
const name = readStringValue(evt.data.name);
const toolCallId = readStringValue(evt.data.toolCallId) ?? "";
const args =
evt.data.args && typeof evt.data.args === "object"
? (evt.data.args as Record<string, unknown>)
: undefined;
if (
onAgentEvent: (() => {
// Normalize commentary deltas/snapshots into full preamble text.
const commentaryTextByItem = new Map<string, string>();
const lastEmittedCommentaryByItem = new Map<string, string>();
return async (evt) => {
lifecycleBackstop.note(evt);
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data.phase === "string";
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
notifyAgentRunStart();
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = readStringValue(evt.data.phase) ?? "";
const name = readStringValue(evt.data.name);
const toolCallId = readStringValue(evt.data.toolCallId) ?? "";
const args =
evt.data.args && typeof evt.data.args === "object"
? (evt.data.args as Record<string, unknown>)
: undefined;
if (
sourceRepliesAreToolOnly &&
toolCallId &&
name &&
(phase === "start" || phase === "update") &&
args &&
isMessagingToolSendAction(name, args)
) {
messageToolOnlyDeliveryToolCallIds.add(toolCallId);
}
if (shouldSuppressProgressAfterMessageToolDelivery()) {
return;
}
if (phase === "start" || phase === "update") {
const toolStartProgressPromise = params.opts?.onToolStart?.({
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),
name,
phase,
args,
detailMode: params.toolProgressDetail,
});
await Promise.all([
params.typingSignals.signalToolStart(),
toolStartProgressPromise,
]);
}
const commandOutput = buildCommandOutputFromToolResultEvent(evt);
if (commandOutput) {
await params.opts?.onCommandOutput?.(commandOutput);
}
}
const suppressItemChannelProgress =
evt.stream === "item" &&
evt.data.suppressChannelProgress === true &&
Boolean(params.opts?.onToolStart);
const itemPhase =
evt.stream === "item" ? readStringValue(evt.data.phase) : "";
const itemName =
evt.stream === "item" ? readStringValue(evt.data.name) : "";
const itemStatus =
evt.stream === "item" ? readStringValue(evt.data.status) : "";
const itemToolCallId =
evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : "";
const completedMessageToolDelivery =
sourceRepliesAreToolOnly &&
toolCallId &&
name &&
(phase === "start" || phase === "update") &&
args &&
isMessagingToolSendAction(name, args)
itemPhase === "end" &&
itemStatus === "completed" &&
itemToolCallId.length > 0 &&
messageToolOnlyDeliveryToolCallIds.has(itemToolCallId);
const suppressProgressAfterMessageToolDelivery =
shouldSuppressProgressAfterMessageToolDelivery();
if (completedMessageToolDelivery) {
messageToolOnlyDeliveryToolCallIds.delete(itemToolCallId);
messageToolOnlyDeliveryCompleted = true;
}
if (
evt.stream === "assistant" &&
readStringValue(evt.data.phase) === "commentary" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
messageToolOnlyDeliveryToolCallIds.add(toolCallId);
const commentaryItemId = readStringValue(evt.data.itemId) ?? "";
const snapshotText = readStringValue(evt.data.text);
const deltaText = readStringValue(evt.data.delta);
const accumulated =
evt.data.replace === true && snapshotText
? snapshotText
: deltaText
? `${commentaryTextByItem.get(commentaryItemId) ?? ""}${deltaText}`
: (snapshotText ?? "");
commentaryTextByItem.set(commentaryItemId, accumulated);
const commentaryText = accumulated.replace(/\s+/g, " ").trim();
if (
commentaryText &&
lastEmittedCommentaryByItem.get(commentaryItemId) !== commentaryText
) {
lastEmittedCommentaryByItem.set(commentaryItemId, commentaryText);
await params.opts?.onItemEvent?.({
itemId: commentaryItemId || undefined,
kind: "preamble",
title: "Preamble",
phase: "update",
progressText: commentaryText,
});
}
}
if (shouldSuppressProgressAfterMessageToolDelivery()) {
return;
}
if (phase === "start" || phase === "update") {
const toolStartProgressPromise = params.opts?.onToolStart?.({
if (
evt.stream === "item" &&
!suppressItemChannelProgress &&
(!suppressProgressAfterMessageToolDelivery ||
completedMessageToolDelivery)
) {
await params.opts?.onItemEvent?.({
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),
name,
phase,
args,
detailMode: params.toolProgressDetail,
kind: readStringValue(evt.data.kind),
title: readStringValue(evt.data.title),
name: itemName,
phase: itemPhase,
status: itemStatus,
summary: readStringValue(evt.data.summary),
progressText: readStringValue(evt.data.progressText),
meta: readStringValue(evt.data.meta),
approvalId: readStringValue(evt.data.approvalId),
approvalSlug: readStringValue(evt.data.approvalSlug),
});
await Promise.all([
params.typingSignals.signalToolStart(),
toolStartProgressPromise,
]);
}
const commandOutput = buildCommandOutputFromToolResultEvent(evt);
if (commandOutput) {
await params.opts?.onCommandOutput?.(commandOutput);
}
}
const suppressItemChannelProgress =
evt.stream === "item" &&
evt.data.suppressChannelProgress === true &&
Boolean(params.opts?.onToolStart);
const itemPhase =
evt.stream === "item" ? readStringValue(evt.data.phase) : "";
const itemName = evt.stream === "item" ? readStringValue(evt.data.name) : "";
const itemStatus =
evt.stream === "item" ? readStringValue(evt.data.status) : "";
const itemToolCallId =
evt.stream === "item" ? (readStringValue(evt.data.toolCallId) ?? "") : "";
const completedMessageToolDelivery =
sourceRepliesAreToolOnly &&
itemPhase === "end" &&
itemStatus === "completed" &&
itemToolCallId.length > 0 &&
messageToolOnlyDeliveryToolCallIds.has(itemToolCallId);
const suppressProgressAfterMessageToolDelivery =
shouldSuppressProgressAfterMessageToolDelivery();
if (completedMessageToolDelivery) {
messageToolOnlyDeliveryToolCallIds.delete(itemToolCallId);
messageToolOnlyDeliveryCompleted = true;
}
if (
evt.stream === "item" &&
!suppressItemChannelProgress &&
(!suppressProgressAfterMessageToolDelivery || completedMessageToolDelivery)
) {
await params.opts?.onItemEvent?.({
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),
kind: readStringValue(evt.data.kind),
title: readStringValue(evt.data.title),
name: itemName,
phase: itemPhase,
status: itemStatus,
summary: readStringValue(evt.data.summary),
progressText: readStringValue(evt.data.progressText),
meta: readStringValue(evt.data.meta),
approvalId: readStringValue(evt.data.approvalId),
approvalSlug: readStringValue(evt.data.approvalSlug),
});
}
if (
evt.stream === "plan" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onPlanUpdate?.({
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
explanation: readStringValue(evt.data.explanation),
steps: Array.isArray(evt.data.steps)
? evt.data.steps.filter(
(step): step is string => typeof step === "string",
)
: undefined,
source: readStringValue(evt.data.source),
});
}
if (
evt.stream === "approval" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onApprovalEvent?.({
phase: readStringValue(evt.data.phase),
kind: readStringValue(evt.data.kind),
status: readStringValue(evt.data.status),
title: readStringValue(evt.data.title),
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),
approvalId: readStringValue(evt.data.approvalId),
approvalSlug: readStringValue(evt.data.approvalSlug),
command: readStringValue(evt.data.command),
host: readStringValue(evt.data.host),
reason: readStringValue(evt.data.reason),
scope: readApprovalScopeValue(evt.data.scope),
message: readStringValue(evt.data.message),
});
}
if (
evt.stream === "command_output" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onCommandOutput?.({
itemId: readStringValue(evt.data.itemId),
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
toolCallId: readStringValue(evt.data.toolCallId),
name: readStringValue(evt.data.name),
output: readStringValue(evt.data.output),
status: readStringValue(evt.data.status),
exitCode:
typeof evt.data.exitCode === "number" || evt.data.exitCode === null
? evt.data.exitCode
if (
evt.stream === "plan" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onPlanUpdate?.({
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
explanation: readStringValue(evt.data.explanation),
steps: Array.isArray(evt.data.steps)
? evt.data.steps.filter(
(step): step is string => typeof step === "string",
)
: undefined,
durationMs:
typeof evt.data.durationMs === "number"
? evt.data.durationMs
source: readStringValue(evt.data.source),
});
}
if (
evt.stream === "approval" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onApprovalEvent?.({
phase: readStringValue(evt.data.phase),
kind: readStringValue(evt.data.kind),
status: readStringValue(evt.data.status),
title: readStringValue(evt.data.title),
itemId: readStringValue(evt.data.itemId),
toolCallId: readStringValue(evt.data.toolCallId),
approvalId: readStringValue(evt.data.approvalId),
approvalSlug: readStringValue(evt.data.approvalSlug),
command: readStringValue(evt.data.command),
host: readStringValue(evt.data.host),
reason: readStringValue(evt.data.reason),
scope: readApprovalScopeValue(evt.data.scope),
message: readStringValue(evt.data.message),
});
}
if (
evt.stream === "command_output" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onCommandOutput?.({
itemId: readStringValue(evt.data.itemId),
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
toolCallId: readStringValue(evt.data.toolCallId),
name: readStringValue(evt.data.name),
output: readStringValue(evt.data.output),
status: readStringValue(evt.data.status),
exitCode:
typeof evt.data.exitCode === "number" || evt.data.exitCode === null
? evt.data.exitCode
: undefined,
durationMs:
typeof evt.data.durationMs === "number"
? evt.data.durationMs
: undefined,
cwd: readStringValue(evt.data.cwd),
});
}
if (
evt.stream === "patch" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onPatchSummary?.({
itemId: readStringValue(evt.data.itemId),
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
toolCallId: readStringValue(evt.data.toolCallId),
name: readStringValue(evt.data.name),
added: Array.isArray(evt.data.added)
? evt.data.added.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
cwd: readStringValue(evt.data.cwd),
});
}
if (
evt.stream === "patch" &&
!shouldSuppressProgressAfterMessageToolDelivery()
) {
await params.opts?.onPatchSummary?.({
itemId: readStringValue(evt.data.itemId),
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
toolCallId: readStringValue(evt.data.toolCallId),
name: readStringValue(evt.data.name),
added: Array.isArray(evt.data.added)
? evt.data.added.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
modified: Array.isArray(evt.data.modified)
? evt.data.modified.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
deleted: Array.isArray(evt.data.deleted)
? evt.data.deleted.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
summary: readStringValue(evt.data.summary),
});
}
if (evt.stream === "compaction") {
const phase = readStringValue(evt.data.phase) ?? "";
const backend = readStringValue(evt.data.backend);
const hookMessages = readCompactionHookMessages(evt.data.messages);
const sendCompactionUserNotices = async (
noticePhase: "start" | "end" | "incomplete",
) => {
if (hookMessages.length > 0) {
await sendCompactionHookMessages(hookMessages);
}
if (notifyUserAboutCompaction) {
await sendCompactionNotice(noticePhase);
}
};
if (phase === "start") {
if (params.opts?.onCompactionStart) {
await params.opts.onCompactionStart();
}
await sendCompactionUserNotices("start");
modified: Array.isArray(evt.data.modified)
? evt.data.modified.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
deleted: Array.isArray(evt.data.deleted)
? evt.data.deleted.filter(
(entry): entry is string => typeof entry === "string",
)
: undefined,
summary: readStringValue(evt.data.summary),
});
}
if (phase === "end") {
const completed = evt.data?.completed === true;
if (completed) {
attemptCompactionCount += 1;
if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) {
const modelRef = formatCompactionModelRef(provider, model);
const consoleMessage =
`codex app-server auto-compaction succeeded for ${modelRef}; ` +
"refreshed session context";
agentCompactionLog.info(
"codex app-server auto-compaction succeeded",
{
event: "codex_app_server_compaction_succeeded",
backend,
provider,
model,
sessionKey: params.sessionKey,
sessionId: effectiveRun.sessionId,
threadId: readStringValue(evt.data.threadId),
turnId: readStringValue(evt.data.turnId),
itemId: readStringValue(evt.data.itemId),
compactionCount: attemptCompactionCount,
consoleMessage,
},
);
if (evt.stream === "compaction") {
const phase = readStringValue(evt.data.phase) ?? "";
const backend = readStringValue(evt.data.backend);
const hookMessages = readCompactionHookMessages(evt.data.messages);
const sendCompactionUserNotices = async (
noticePhase: "start" | "end" | "incomplete",
) => {
if (hookMessages.length > 0) {
await sendCompactionHookMessages(hookMessages);
}
if (params.opts?.onCompactionEnd) {
await params.opts.onCompactionEnd();
if (notifyUserAboutCompaction) {
await sendCompactionNotice(noticePhase);
}
};
if (phase === "start") {
if (params.opts?.onCompactionStart) {
await params.opts.onCompactionStart();
}
await sendCompactionUserNotices("start");
}
if (phase === "end") {
const completed = evt.data?.completed === true;
if (completed) {
attemptCompactionCount += 1;
if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) {
const modelRef = formatCompactionModelRef(provider, model);
const consoleMessage =
`codex app-server auto-compaction succeeded for ${modelRef}; ` +
"refreshed session context";
agentCompactionLog.info(
"codex app-server auto-compaction succeeded",
{
event: "codex_app_server_compaction_succeeded",
backend,
provider,
model,
sessionKey: params.sessionKey,
sessionId: effectiveRun.sessionId,
threadId: readStringValue(evt.data.threadId),
turnId: readStringValue(evt.data.turnId),
itemId: readStringValue(evt.data.itemId),
compactionCount: attemptCompactionCount,
consoleMessage,
},
);
}
if (params.opts?.onCompactionEnd) {
await params.opts.onCompactionEnd();
}
await sendCompactionUserNotices("end");
} else {
await sendCompactionUserNotices("incomplete");
}
await sendCompactionUserNotices("end");
} else {
await sendCompactionUserNotices("incomplete");
}
}
}
},
};
})(),
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
@@ -29,6 +29,7 @@ export function createBlockReplyCoalescer(params: {
let bufferReplyToId: ReplyPayload["replyToId"];
let bufferAudioAsVoice: ReplyPayload["audioAsVoice"];
let bufferIsReasoning: ReplyPayload["isReasoning"];
let bufferIsCommentary: ReplyPayload["isCommentary"];
let bufferIsCompactionNotice: ReplyPayload["isCompactionNotice"];
let bufferIsFallbackNotice: ReplyPayload["isFallbackNotice"];
let bufferIsStatusNotice: ReplyPayload["isStatusNotice"];
@@ -48,6 +49,7 @@ export function createBlockReplyCoalescer(params: {
bufferReplyToId = undefined;
bufferAudioAsVoice = undefined;
bufferIsReasoning = undefined;
bufferIsCommentary = undefined;
bufferIsCompactionNotice = undefined;
bufferIsFallbackNotice = undefined;
bufferIsStatusNotice = undefined;
@@ -58,6 +60,7 @@ export function createBlockReplyCoalescer(params: {
bufferReplyToId = payload.replyToId;
bufferAudioAsVoice = payload.audioAsVoice;
bufferIsReasoning = payload.isReasoning;
bufferIsCommentary = payload.isCommentary;
bufferIsCompactionNotice = payload.isCompactionNotice;
bufferIsFallbackNotice = payload.isFallbackNotice;
bufferIsStatusNotice = payload.isStatusNotice;
@@ -92,6 +95,7 @@ export function createBlockReplyCoalescer(params: {
replyToId: bufferReplyToId,
audioAsVoice: bufferAudioAsVoice,
isReasoning: bufferIsReasoning,
isCommentary: bufferIsCommentary,
isCompactionNotice: bufferIsCompactionNotice,
isFallbackNotice: bufferIsFallbackNotice,
isStatusNotice: bufferIsStatusNotice,
@@ -107,8 +111,10 @@ export function createBlockReplyCoalescer(params: {
!bufferAudioAsVoice &&
!payload.audioAsVoice &&
!payload.isReasoning &&
!payload.isCommentary &&
!isReplyPayloadStatusNotice(payload) &&
!bufferIsReasoning &&
!bufferIsCommentary &&
!isReplyPayloadStatusNotice({
isCompactionNotice: bufferIsCompactionNotice,
isFallbackNotice: bufferIsFallbackNotice,
@@ -173,6 +179,7 @@ export function createBlockReplyCoalescer(params: {
const visibilityConflict =
bufferText &&
(bufferIsReasoning !== payload.isReasoning ||
bufferIsCommentary !== payload.isCommentary ||
bufferIsCompactionNotice !== payload.isCompactionNotice ||
bufferIsFallbackNotice !== payload.isFallbackNotice ||
isReplyPayloadStatusNotice({
@@ -7967,6 +7967,46 @@ describe("dispatchReplyFromConfig", () => {
]);
});
it("suppresses isCommentary payloads from final replies by default", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const ctx = buildTestCtx({ Provider: "whatsapp" });
const replyResolver = async () =>
[
{ text: "commentary...", isCommentary: true },
{ text: "The answer is 42" },
] satisfies ReplyPayload[];
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
expect(finalCalls).toHaveLength(1);
expect((finalCalls[0]?.[0] as ReplyPayload | undefined)?.text).toBe("The answer is 42");
});
it("delivers isCommentary final replies when the channel opts in", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const ctx = buildTestCtx({ Provider: "discord", Surface: "discord" });
const replyResolver = async () =>
[
{ text: "commentary...", isCommentary: true },
{ text: "The answer is 42" },
] satisfies ReplyPayload[];
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher,
replyOptions: { commentaryPayloadsEnabled: true },
replyResolver,
});
const finalCalls = (dispatcher.sendFinalReply as ReturnType<typeof vi.fn>).mock.calls;
expect(finalCalls.map((call) => (call[0] as ReplyPayload).text)).toEqual([
"commentary...",
"The answer is 42",
]);
});
it("does not synthesize opted-in final reasoning payloads into TTS media", async () => {
setNoAbort();
ttsMocks.state.synthesizeFinalAudio = true;
@@ -7989,6 +8029,28 @@ describe("dispatchReplyFromConfig", () => {
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(reasoningPayload);
});
it("does not synthesize opted-in final commentary payloads into TTS media", async () => {
setNoAbort();
ttsMocks.state.synthesizeFinalAudio = true;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({ Provider: "discord", Surface: "discord" });
const commentaryPayload = {
text: "commentary...",
isCommentary: true,
} satisfies ReplyPayload;
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher,
replyOptions: { commentaryPayloadsEnabled: true },
replyResolver: async () => commentaryPayload,
});
expect(ttsMocks.maybeApplyTtsToPayload).not.toHaveBeenCalled();
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith(commentaryPayload);
});
it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => {
setNoAbort();
const dispatcher = createDispatcher();
@@ -8054,6 +8116,69 @@ describe("dispatchReplyFromConfig", () => {
expect(blockTtsCalls.map((call) => call.payload?.text)).toEqual(["The answer is 42"]);
});
it("suppresses isCommentary payloads from block replies by default", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const ctx = buildTestCtx({ Provider: "whatsapp" });
const blockReplySentTexts: string[] = [];
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
): Promise<ReplyPayload> => {
await opts?.onBlockReply?.({ text: "commentary...", isCommentary: true });
await opts?.onBlockReply?.({ text: "The answer is 42" });
return { text: "The answer is 42" };
};
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
(payload: ReplyPayload) => {
if (payload.text) {
blockReplySentTexts.push(payload.text);
}
return true;
},
);
await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver });
expect(blockReplySentTexts).not.toContain("commentary...");
expect(blockReplySentTexts).toContain("The answer is 42");
});
it("delivers opted-in block commentary payloads without applying TTS", async () => {
setNoAbort();
const dispatcher = createDispatcher();
const ctx = buildTestCtx({ Provider: "discord", Surface: "discord" });
const blockReplySentTexts: string[] = [];
const replyResolver = async (
_ctx: MsgContext,
opts?: GetReplyOptions,
): Promise<ReplyPayload | undefined> => {
await opts?.onBlockReply?.({ text: "commentary...", isCommentary: true });
await opts?.onBlockReply?.({ text: "The answer is 42" });
return undefined;
};
(dispatcher.sendBlockReply as ReturnType<typeof vi.fn>).mockImplementation(
(payload: ReplyPayload) => {
if (payload.text) {
blockReplySentTexts.push(payload.text);
}
return true;
},
);
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher,
replyOptions: { commentaryPayloadsEnabled: true },
replyResolver,
});
expect(blockReplySentTexts).toEqual(["commentary...", "The answer is 42"]);
const blockTtsCalls = ttsMocks.maybeApplyTtsToPayload.mock.calls
.map(([call]) => call as { kind?: unknown; payload?: ReplyPayload })
.filter((call) => call.kind === "block");
expect(blockTtsCalls.map((call) => call.payload?.text)).toEqual(["The answer is 42"]);
});
it("strips split TTS directives from streamed block text before delivery", async () => {
setNoAbort();
ttsMocks.state.synthesizeFinalAudio = true;
+25 -5
View File
@@ -2052,6 +2052,7 @@ export async function dispatchReplyFromConfig(
suppressHookReplyLifecycle,
} = sourceReplyPolicy;
const reasoningPayloadsEnabled = params.replyOptions?.reasoningPayloadsEnabled === true;
const commentaryPayloadsEnabled = params.replyOptions?.commentaryPayloadsEnabled === true;
const attachSourceReplyDeliveryMode = (
result: DispatchFromConfigResult,
): DispatchFromConfigResult =>
@@ -2495,7 +2496,7 @@ export async function dispatchReplyFromConfig(
finalReplyDeliveryStarted = true;
}
const ttsPayload =
payload.isReasoning === true
payload.isReasoning === true || payload.isCommentary === true
? payload
: await maybeApplyTtsToReplyPayload({
payload,
@@ -3073,6 +3074,8 @@ export async function dispatchReplyFromConfig(
suppressTyping: typing.suppressTyping,
onPartialReply: wrapProgressCallback(params.replyOptions?.onPartialReply),
onReasoningStream: wrapProgressCallback(params.replyOptions?.onReasoningStream),
streamReasoningInNonStreamModes:
params.replyOptions?.streamReasoningInNonStreamModes,
onReasoningEnd: wrapProgressCallback(params.replyOptions?.onReasoningEnd),
onAssistantMessageStart: wrapProgressCallback(
params.replyOptions?.onAssistantMessageStart,
@@ -3095,6 +3098,7 @@ export async function dispatchReplyFromConfig(
canForwardSuppressedSourceItemEvents ||
params.replyOptions?.commentaryProgressEnabled,
reasoningPayloadsEnabled,
commentaryPayloadsEnabled,
onCommandOutput: wrapProgressCallback(params.replyOptions?.onCommandOutput, {
forwardWhenSourceDeliverySuppressed: true,
requiresToolSummaryVisibility: true,
@@ -3314,6 +3318,7 @@ export async function dispatchReplyFromConfig(
}
if (
payload.isReasoning !== true &&
payload.isCommentary !== true &&
hasOutboundReplyContent(payload, { trimText: true })
) {
markInboundDedupeReplayUnsafe();
@@ -3328,11 +3333,22 @@ export async function dispatchReplyFromConfig(
if (payload.isReasoning === true && !reasoningPayloadsEnabled) {
return;
}
// Durable commentary is a channel-owned lane; generic channels keep the
// historical suppression unless they explicitly opt in.
if (payload.isCommentary === true && !commentaryPayloadsEnabled) {
return;
}
// Accumulate block text for TTS generation after streaming.
// Exclude status notices — they are informational UI signals
// and must not be synthesised into the spoken reply.
// and must not be synthesised into the spoken reply. Display
// lanes stay out too: they are presentation, never final text.
const isStatusNotice = isReplyPayloadStatusNotice(payload);
if (payload.text && !isStatusNotice && payload.isReasoning !== true) {
if (
payload.text &&
!isStatusNotice &&
payload.isReasoning !== true &&
payload.isCommentary !== true
) {
const joinsBufferedTtsDirective =
cleanBlockTtsDirectiveText?.hasBufferedDirectiveText() === true;
if (accumulatedBlockText.length > 0) {
@@ -3349,7 +3365,8 @@ export async function dispatchReplyFromConfig(
payload.text &&
cleanBlockTtsDirectiveText &&
!isStatusNotice &&
payload.isReasoning !== true
payload.isReasoning !== true &&
payload.isCommentary !== true
? (() => {
const text = cleanBlockTtsDirectiveText.push(payload.text);
return copyReplyPayloadMetadata(payload, {
@@ -3379,7 +3396,7 @@ export async function dispatchReplyFromConfig(
return;
}
const ttsPayload =
payload.isReasoning === true
payload.isReasoning === true || payload.isCommentary === true
? visiblePayload
: await maybeApplyTtsToReplyPayload({
payload: visiblePayload,
@@ -3506,6 +3523,9 @@ export async function dispatchReplyFromConfig(
if (reply.isReasoning === true && !reasoningPayloadsEnabled) {
continue;
}
if (reply.isCommentary === true && !commentaryPayloadsEnabled) {
continue;
}
if (suppressDelivery && !shouldDeliverDespiteSourceReplySuppression(reply)) {
if (hasOutboundReplyContent(reply, { trimText: true })) {
logVerbose(
+8 -1
View File
@@ -180,7 +180,14 @@ export function createBlockReplyDeliveryHandler(params: {
trackingPayload: blockPayload,
payload: blockPayload,
});
} else if (blockHasNonTextContent) {
} else if (
blockHasNonTextContent ||
blockPayload.isReasoning === true ||
blockPayload.isCommentary === true
) {
// Reasoning and commentary are separate display lanes that never merge into
// the assistant's final text, so they must be delivered directly even when
// block streaming is off; presentation gating stays downstream (dispatch gate).
await sendDirectBlockReply({
onBlockReply: params.onBlockReply,
directlySentBlockKeys: params.directlySentBlockKeys,
+122 -146
View File
@@ -1,10 +1,7 @@
// Progress draft compositor tests cover streamed draft composition for channel progress updates.
import { describe, expect, it, vi } from "vitest";
import { createChannelProgressDraftCompositor } from "./progress-draft-compositor.js";
import {
DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS,
mergeChannelProgressDraftLine,
} from "./streaming.js";
import { DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS } from "./streaming.js";
describe("createChannelProgressDraftCompositor", () => {
it("keeps the progress label visible when tool lines are hidden", async () => {
@@ -24,112 +21,84 @@ describe("createChannelProgressDraftCompositor", () => {
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
});
it("materializes a label-only progress draft after upstream answer activity", async () => {
vi.useFakeTimers();
try {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.noteActivity();
expect(update).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS);
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
} finally {
vi.useRealTimers();
}
});
it("does not materialize delayed answer activity after final delivery starts or lands", async () => {
vi.useFakeTimers();
try {
for (const markFinal of ["markFinalReplyStarted", "markFinalReplyDelivered"] as const) {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.noteActivity();
progress[markFinal]();
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS);
expect(update).not.toHaveBeenCalled();
expect(progress.hasStarted).toBe(false);
}
} finally {
vi.useRealTimers();
}
});
it("starts a label-only progress draft on repeated upstream answer activity", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.noteActivity();
await progress.noteActivity();
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
});
it("passes structured progress lines to draft updates", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
const line = {
kind: "tool" as const,
text: "🛠️ Exec: git status",
label: "Exec",
icon: "🛠️",
detail: "git status",
};
await progress.pushToolProgress(line, { startImmediately: true });
expect(update).toHaveBeenCalledWith("Shelling\n\n🛠️ Exec: git status", {
flush: true,
lines: [line],
});
});
it("keeps reasoning details hidden when tool progress lines are hidden", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
it("gates window thinking on its own flag, independent of tool progress", async () => {
// thinking: false hides thoughts even though toolProgress stays on…
const hiddenUpdate = vi.fn();
const hidden = createChannelProgressDraftCompositor({
entry: {
streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } },
streaming: { mode: "progress", progress: { label: "Shelling" } },
},
mode: "progress",
active: true,
seed: "test",
reasoningGate: false,
update: hiddenUpdate,
});
await hidden.pushToolProgress("🛠️ Exec", { startImmediately: true });
await hidden.pushReasoningProgress("Reading files");
expect(hiddenUpdate.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(true);
const defaultUpdate = vi.fn();
const sharedDefault = createChannelProgressDraftCompositor({
entry: {
streaming: {
mode: "progress",
progress: { label: "Shelling", toolProgress: false },
},
},
mode: "progress",
active: true,
seed: "test",
update: defaultUpdate,
});
await sharedDefault.pushToolProgress("🛠️ Exec", { startImmediately: true });
await sharedDefault.pushReasoningProgress("Reading files");
expect(defaultUpdate.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(
true,
);
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: {
streaming: {
mode: "progress",
progress: { label: "Shelling", toolProgress: false },
},
},
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
reasoningGate: true,
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Reading files");
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🧠 _Reading files_", {
lines: ["🧠 _Reading files_"],
});
});
it("re-arms the draft for a queued turn after the primary final settled", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
mode: "progress",
active: true,
seed: "test",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Reading files");
progress.markFinalReplyStarted();
progress.markFinalReplyDelivered();
expect(await progress.pushReasoningProgress("queued-turn thinking")).toBe(false);
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
expect(update.mock.calls.every(([text]) => !String(text).includes("Reading"))).toBe(true);
// New assistant message boundary on a queued/followup turn.
progress.beginNewTurn();
await progress.start();
await progress.pushReasoningProgress("queued-turn thinking", { snapshot: true });
expect(update).toHaveBeenCalled();
});
it("does not resurrect progress after suppression", async () => {
@@ -155,6 +124,7 @@ describe("createChannelProgressDraftCompositor", () => {
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
update,
});
@@ -162,34 +132,53 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Reading");
await progress.pushReasoningProgress(" files");
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Reading files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Reading files_"],
}),
);
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Reading files_", {
lines: ["🛠️ Exec", "🧠 _Reading files_"],
});
});
it("resets reasoning deltas without clearing tool progress", async () => {
it("labels window narration with a 💬 prefix", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
mode: "progress",
active: true,
seed: "test",
commentaryLinePrefix: "💬 ",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("Checking files");
progress.resetReasoningProgress();
await progress.pushReasoningProgress("Now testing");
await progress.pushCommentaryProgress("Checking the workspace", { itemId: "c1" });
const rendered = update.mock.calls.map((call) => call[0]);
expect(rendered).toContain("Shelling\n\n💬 _Checking the workspace_");
});
it("interleaves reasoning bursts with tool calls in arrival order", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: {
streaming: { mode: "progress", progress: { label: "Shelling", maxLines: 8 } },
},
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
update,
});
// thought1 → tool1 → thought2 → tool2: each thought is its own line,
// appended in order, not collapsed into a single replaced line.
await progress.pushReasoningProgress("Listing the workspace");
await progress.pushToolProgress("🛠️ ls", { startImmediately: true });
await progress.pushReasoningProgress("Picking the largest");
await progress.pushToolProgress("🛠️ wc", { startImmediately: true });
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Now testing_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Now testing_"],
}),
"Shelling\n\n🧠 _Listing the workspace_\n🛠️ ls\n🧠 _Picking the largest_\n🛠️ wc",
{
lines: ["🧠 _Listing the workspace_", "🛠️ ls", "🧠 _Picking the largest_", "🛠️ wc"],
},
);
});
@@ -200,18 +189,16 @@ describe("createChannelProgressDraftCompositor", () => {
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
update,
});
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
await progress.pushReasoningProgress("<think>Checking files</think>Final answer prose");
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Checking files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Checking files_"],
}),
);
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Checking files_", {
lines: ["🛠️ Exec", "🧠 _Checking files_"],
});
});
it("waits for complete reasoning tags before showing tagged progress", async () => {
@@ -238,6 +225,7 @@ describe("createChannelProgressDraftCompositor", () => {
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
update,
});
@@ -245,12 +233,9 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("<thin");
await progress.pushReasoningProgress("k>Checking files</think>Final answer prose");
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Checking files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Checking files_"],
}),
);
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Checking files_", {
lines: ["🛠️ Exec", "🧠 _Checking files_"],
});
});
it("keeps literal reasoning tags inside code blocks", async () => {
@@ -260,6 +245,7 @@ describe("createChannelProgressDraftCompositor", () => {
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
update,
});
@@ -267,10 +253,10 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("```html\n<think>literal</think>\n```");
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n _```html <think>literal</think> ```_",
expect.objectContaining({
lines: ["🛠️ Exec", "_```html <think>literal</think> ```_"],
}),
"Shelling\n\n🛠️ Exec\n🧠 _```html <think>literal</think> ```_",
{
lines: ["🛠️ Exec", "🧠 _```html <think>literal</think> ```_"],
},
);
});
@@ -281,6 +267,7 @@ describe("createChannelProgressDraftCompositor", () => {
mode: "progress",
active: true,
seed: "test",
reasoningLinePrefix: "🧠 ",
update,
});
@@ -288,20 +275,9 @@ describe("createChannelProgressDraftCompositor", () => {
await progress.pushReasoningProgress("Thinking\n\n_Reading_");
await progress.pushReasoningProgress("Thinking\n\n_Reading files_");
expect(update).toHaveBeenLastCalledWith(
"Shelling\n\n🛠️ Exec\n• _Reading files_",
expect.objectContaining({
lines: ["🛠️ Exec", "_Reading files_"],
}),
);
});
it("preserves repeated plain progress lines when separated by another event", () => {
const lines = mergeChannelProgressDraftLine(["Starting", "Finished"], "Starting", {
maxLines: 4,
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Reading files_", {
lines: ["🛠️ Exec", "🧠 _Reading files_"],
});
expect(lines).toEqual(["Starting", "Finished", "Starting"]);
});
it("logs a timer-fired start failure via the gate's default boundary logger", async () => {
+50 -18
View File
@@ -45,11 +45,25 @@ export function createChannelProgressDraftCompositor(params: {
formatLine?: (line: string) => string;
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
reasoningLinePrefix?: string;
commentaryLinePrefix?: string;
reasoningGate?: boolean;
commentaryItalics?: boolean;
}) {
const reasoningLinePrefix = params.reasoningLinePrefix ?? "";
const commentaryLinePrefix = params.commentaryLinePrefix ?? "";
const commentaryItalics = params.commentaryItalics ?? true;
const stripLaneItalics = (text: string): string =>
text
.split("\n")
.map((line) => line.replace(/^_(.*)_$/su, "$1"))
.join("\n");
const previewToolProgressEnabled =
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
const commentaryProgressEnabled =
params.active && resolveChannelStreamingProgressCommentary(params.entry);
const thinkingProgressEnabled =
params.active && (params.reasoningGate ?? previewToolProgressEnabled);
const suppressDefaultToolProgressMessages =
params.active &&
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
@@ -147,6 +161,13 @@ export function createChannelProgressDraftCompositor(params: {
if (shouldStoreLine && nextLines === lines) {
return false;
}
// A work line lands between reasoning bursts: commit the current thinking
// line so the next thought appends as its own line, interleaved with tools
// in arrival order, instead of replacing the prior thought.
if (shouldStoreLine) {
reasoningRawText = "";
lastReasoningLine = undefined;
}
if (shouldStoreLine && params.tryNativeUpdate) {
// Native draft updates get unformatted text; if the channel accepts it,
// keep local state aligned without sending a generic draft message.
@@ -201,11 +222,20 @@ export function createChannelProgressDraftCompositor(params: {
},
markFinalReplyStarted() {
finalReplyStarted = true;
gate.cancel();
},
markFinalReplyDelivered() {
finalReplyDelivered = true;
gate.cancel();
},
// Queued/followup turns reuse this compositor after the primary turn's
// final reply settled it. Re-arm the draft lanes so the queued turn gets
// the same in-progress rendering as a primary turn (path independence).
beginNewTurn() {
if (!finalReplyStarted && !finalReplyDelivered) {
return;
}
finalReplyStarted = false;
finalReplyDelivered = false;
clearProgressState(false);
},
reset() {
clearProgressState(false);
@@ -250,7 +280,8 @@ export function createChannelProgressDraftCompositor(params: {
params.mode !== "progress" ||
!text ||
progressSuppressed ||
finalReplyDelivered
finalReplyDelivered ||
!thinkingProgressEnabled
) {
return false;
}
@@ -261,26 +292,25 @@ export function createChannelProgressDraftCompositor(params: {
if (!normalized) {
return false;
}
const displayLine = formatReasoningProgressDisplayLine(
const compactLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.entry),
);
if (!displayLine) {
if (!compactLine) {
return false;
}
if (previewToolProgressEnabled) {
// Reasoning streams usually arrive as deltas. Replace the previous
// reasoning line so the draft stays compact instead of appending noise.
const priorIndex =
lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine);
if (priorIndex >= 0) {
lines = [...lines];
lines[priorIndex] = displayLine;
} else {
lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry));
}
lastReasoningLine = displayLine;
const displayLine = `${reasoningLinePrefix}${compactLine}`;
// Reasoning streams usually arrive as deltas. Replace the previous
// reasoning line so the draft stays compact instead of appending noise.
const priorIndex =
lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine);
if (priorIndex >= 0) {
lines = [...lines];
lines[priorIndex] = displayLine;
} else {
lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry));
}
lastReasoningLine = displayLine;
const progressActive = await gate.noteWork();
if (progressActive && gate.hasStarted) {
return await render();
@@ -310,8 +340,10 @@ export function createChannelProgressDraftCompositor(params: {
}
const line: ChannelProgressDraftLine = {
id: lineId,
// The lane marker (💬, matching 🧠 thinking / 🛠️ tools) is a per-channel
// presentation choice supplied via commentaryLinePrefix; default none.
text: `${commentaryLinePrefix}${commentaryItalics ? normalized : stripLaneItalics(normalized)}`,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
};
@@ -0,0 +1,154 @@
// Verifies exec command highlighting resolution across global and agent-scoped config.
import { describe, expect, it } from "vitest";
import { resolveExecCommandHighlighting } from "./exec-command-highlighting.js";
import type { OpenClawConfig } from "./types.openclaw.js";
describe("resolveExecCommandHighlighting", () => {
it("defaults to false when no config is provided", () => {
expect(resolveExecCommandHighlighting({})).toBe(false);
});
it("defaults to false when config is null", () => {
expect(resolveExecCommandHighlighting({ config: null })).toBe(false);
});
it("reads global exec commandHighlighting", () => {
const config = {
tools: { exec: { commandHighlighting: true } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config })).toBe(true);
});
it("returns false when global exec commandHighlighting is disabled", () => {
const config = {
tools: { exec: { commandHighlighting: false } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config })).toBe(false);
});
it("agent-scoped true overrides global false", () => {
const config = {
agents: {
list: [
{
id: "alpha",
tools: { exec: { commandHighlighting: true } },
},
],
},
tools: { exec: { commandHighlighting: false } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(true);
});
it("agent-scoped false overrides global true", () => {
const config = {
agents: {
list: [
{
id: "alpha",
tools: { exec: { commandHighlighting: false } },
},
],
},
tools: { exec: { commandHighlighting: true } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false);
});
it("agent without override falls back to global true", () => {
const config = {
agents: {
list: [{ id: "alpha" }],
},
tools: { exec: { commandHighlighting: true } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(true);
});
it("agent without override falls back to global false", () => {
const config = {
agents: {
list: [{ id: "alpha" }],
},
tools: { exec: { commandHighlighting: false } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false);
});
it("agent with explicit true and no global returns true", () => {
const config = {
agents: {
list: [
{
id: "alpha",
tools: { exec: { commandHighlighting: true } },
},
],
},
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(true);
});
it("agent with explicit false and no global falls back to false", () => {
const config = {
agents: {
list: [
{
id: "alpha",
tools: { exec: { commandHighlighting: false } },
},
],
},
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false);
});
it("falls back to global config when agent ID is not in the agent list", () => {
const config = {
tools: { exec: { commandHighlighting: true } },
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "nonexistent" })).toBe(true);
});
it("agent ID normalization matches agent list entries", () => {
// normalizeAgentId lowercases and trims the agent ID, so "ALPHA" should
// match an entry with id "alpha".
const config = {
agents: {
list: [
{
id: "alpha",
tools: { exec: { commandHighlighting: true } },
},
],
},
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "ALPHA" })).toBe(true);
});
it("unrelated agent ID does not affect the result", () => {
const config = {
agents: {
list: [
{
id: "other",
tools: { exec: { commandHighlighting: true } },
},
],
},
} satisfies OpenClawConfig;
expect(resolveExecCommandHighlighting({ config, agentId: "alpha" })).toBe(false);
});
});
+4 -1
View File
@@ -1,6 +1,7 @@
// Defines Discord channel configuration types.
import type {
ChannelPreviewStreamingConfig,
ChannelStreamingProgressConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -22,7 +23,9 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ
import type { TtsConfig } from "./types.tts.js";
export type DiscordStreamMode = "off" | "partial" | "block" | "progress";
export type DiscordChannelStreamingConfig = ChannelPreviewStreamingConfig;
export type DiscordChannelStreamingConfig = Omit<ChannelPreviewStreamingConfig, "progress"> & {
progress?: ChannelStreamingProgressConfig;
};
export type DiscordPluralKitConfig = {
enabled?: boolean;
+4 -1
View File
@@ -103,6 +103,7 @@ const ChannelStreamingProgressSchema = z
commentary: z.boolean().optional(),
})
.strict();
const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema;
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
nativeTaskCards: z.boolean().optional(),
}).strict();
@@ -125,7 +126,9 @@ const ChannelPreviewStreamingConfigSchema = z
const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
preview: ChannelStreamingPreviewSchema.optional(),
}).strict();
const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema;
const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
progress: DiscordStreamingProgressSchema.optional(),
}).strict();
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
nativeTransport: z.boolean().optional(),
progress: SlackStreamingProgressSchema.optional(),
+87
View File
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
resetAttachGrantsForTest,
attachGrantStoreSize,
mintAttachGrant,
resolveAttachGrant,
revokeAttachGrant,
revokeAttachGrantsForSession,
sweepExpiredAttachGrants,
} from "./mcp-grant-store.js";
const T0 = 1_000_000_000_000;
describe("mcp-grant-store", () => {
beforeEach(() => resetAttachGrantsForTest());
it("mints a grant bound to the sessionKey with a token and a TTL window", () => {
const g = mintAttachGrant({ sessionKey: "agent:main:main", ttlMs: 60_000, nowMs: T0 });
expect(g.sessionKey).toBe("agent:main:main");
expect(g.token).toMatch(/^[0-9a-f]{64}$/);
expect(g.issuedAtMs).toBe(T0);
expect(g.expiresAtMs).toBe(T0 + 60_000);
});
it("requires a non-empty sessionKey", () => {
expect(() => mintAttachGrant({ sessionKey: " ", nowMs: T0 })).toThrow();
});
it("resolves a live grant and drops it once expired (TTL)", () => {
const g = mintAttachGrant({ sessionKey: "agent:main:x", ttlMs: 1_000, nowMs: T0 });
expect(resolveAttachGrant(g.token, T0)?.sessionKey).toBe("agent:main:x");
expect(resolveAttachGrant(g.token, T0 + 999)?.sessionKey).toBe("agent:main:x");
expect(resolveAttachGrant(g.token, T0 + 1_000)).toBeUndefined();
expect(resolveAttachGrant(g.token, T0 + 1_001)).toBeUndefined();
expect(attachGrantStoreSize()).toBe(0);
});
it("returns undefined for an unknown token (no scope without a grant)", () => {
expect(resolveAttachGrant("deadbeef", T0)).toBeUndefined();
});
it("binds the sessionKey to the grant (token carries scope identity, not the caller)", () => {
const a = mintAttachGrant({ sessionKey: "agent:main:telegram:1", nowMs: T0 });
const b = mintAttachGrant({ sessionKey: "agent:main:telegram:2", nowMs: T0 });
expect(resolveAttachGrant(a.token, T0)?.sessionKey).toBe("agent:main:telegram:1");
expect(resolveAttachGrant(b.token, T0)?.sessionKey).toBe("agent:main:telegram:2");
expect(a.token).not.toBe(b.token);
});
it("revokes by token", () => {
const g = mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 });
expect(revokeAttachGrant(g.token)).toBe(true);
expect(resolveAttachGrant(g.token, T0)).toBeUndefined();
expect(revokeAttachGrant(g.token)).toBe(false);
});
it("revokes all grants for a session", () => {
mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 });
mintAttachGrant({ sessionKey: "agent:main:x", nowMs: T0 });
mintAttachGrant({ sessionKey: "agent:main:y", nowMs: T0 });
expect(revokeAttachGrantsForSession("agent:main:x")).toBe(2);
expect(attachGrantStoreSize()).toBe(1);
});
it("clamps TTL: default for non-positive, ceiling at 12h", () => {
const def = mintAttachGrant({ sessionKey: "s", nowMs: T0 });
expect(def.expiresAtMs).toBe(T0 + 60 * 60 * 1000);
const zero = mintAttachGrant({ sessionKey: "s", ttlMs: 0, nowMs: T0 });
expect(zero.expiresAtMs).toBe(T0 + 60 * 60 * 1000);
const huge = mintAttachGrant({ sessionKey: "s", ttlMs: 999 * 60 * 60 * 1000, nowMs: T0 });
expect(huge.expiresAtMs).toBe(T0 + 12 * 60 * 60 * 1000);
});
it("sweeps expired grants", () => {
mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 });
mintAttachGrant({ sessionKey: "s", ttlMs: 5_000, nowMs: T0 });
expect(sweepExpiredAttachGrants(T0 + 2_000)).toBe(1);
expect(attachGrantStoreSize()).toBe(1);
});
it("evicts expired grants on mint, bounding the store (no accumulation)", () => {
mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 });
expect(attachGrantStoreSize()).toBe(1);
mintAttachGrant({ sessionKey: "s", ttlMs: 1_000, nowMs: T0 + 5_000 });
expect(attachGrantStoreSize()).toBe(1);
});
});
+96
View File
@@ -0,0 +1,96 @@
import crypto from "node:crypto";
export interface McpAttachGrant {
/** Opaque bearer presented as `Authorization: Bearer <token>`. */
readonly token: string;
/** The openclaw session this grant is bound to; tool scope is resolved for this key. */
readonly sessionKey: string;
/** Absolute expiry (ms epoch). */
readonly expiresAtMs: number;
/** Absolute mint time (ms epoch). */
readonly issuedAtMs: number;
}
const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1h
const MAX_TTL_MS = 12 * 60 * 60 * 1000;
const grantsByToken = new Map<string, McpAttachGrant>();
function clampTtlMs(ttlMs: number | undefined): number {
if (!Number.isFinite(ttlMs) || (ttlMs as number) <= 0) {
return DEFAULT_TTL_MS;
}
return Math.min(ttlMs as number, MAX_TTL_MS);
}
export function mintAttachGrant(params: {
sessionKey: string;
ttlMs?: number;
nowMs?: number;
}): McpAttachGrant {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("mintAttachGrant: sessionKey is required");
}
const nowMs = params.nowMs ?? Date.now();
// Mint sweeps stale entries so abandoned grants do not accumulate.
sweepExpiredAttachGrants(nowMs);
const grant: McpAttachGrant = {
token: crypto.randomBytes(32).toString("hex"),
sessionKey,
issuedAtMs: nowMs,
expiresAtMs: nowMs + clampTtlMs(params.ttlMs),
};
grantsByToken.set(grant.token, grant);
return grant;
}
export function resolveAttachGrant(
token: string,
nowMs: number = Date.now(),
): McpAttachGrant | undefined {
const grant = grantsByToken.get(token);
if (!grant) {
return undefined;
}
if (nowMs >= grant.expiresAtMs) {
grantsByToken.delete(token);
return undefined;
}
return grant;
}
export function revokeAttachGrant(token: string): boolean {
return grantsByToken.delete(token);
}
export function revokeAttachGrantsForSession(sessionKey: string): number {
const key = sessionKey.trim();
let removed = 0;
for (const [token, grant] of grantsByToken) {
if (grant.sessionKey === key) {
grantsByToken.delete(token);
removed += 1;
}
}
return removed;
}
export function sweepExpiredAttachGrants(nowMs: number = Date.now()): number {
let removed = 0;
for (const [token, grant] of grantsByToken) {
if (nowMs >= grant.expiresAtMs) {
grantsByToken.delete(token);
removed += 1;
}
}
return removed;
}
export function attachGrantStoreSize(): number {
return grantsByToken.size;
}
export function resetAttachGrantsForTest(): void {
grantsByToken.clear();
}
+31 -7
View File
@@ -10,6 +10,7 @@ import { isTruthyEnvValue } from "../infra/env.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { getHeader } from "./http-utils.js";
import { resolveAttachGrant } from "./mcp-grant-store.js";
import { isLoopbackAddress } from "./net.js";
import { checkBrowserOrigin } from "./origin-check.js";
@@ -112,14 +113,19 @@ function resolveMcpSender(params: {
req: IncomingMessage;
ownerToken: string;
nonOwnerToken: string;
}): { senderIsOwner: boolean } | undefined {
}): { senderIsOwner: boolean; boundSessionKey?: string } | undefined {
const authHeader = getHeader(params.req, "authorization") ?? "";
const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`);
const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`);
if (!ownerTokenMatched && !nonOwnerTokenMatched) {
return undefined;
if (ownerTokenMatched || nonOwnerTokenMatched) {
return { senderIsOwner: ownerTokenMatched };
}
return { senderIsOwner: ownerTokenMatched };
const grantToken = authHeader.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : "";
const grant = grantToken ? resolveAttachGrant(grantToken) : undefined;
if (grant) {
return { senderIsOwner: false, boundSessionKey: grant.sessionKey };
}
return undefined;
}
export function validateMcpLoopbackRequest(params: {
@@ -128,7 +134,7 @@ export function validateMcpLoopbackRequest(params: {
ownerToken: string;
nonOwnerToken: string;
onSseResponse?: (res: ServerResponse) => void;
}): { senderIsOwner: boolean } | null {
}): { senderIsOwner: boolean; boundSessionKey?: string } | null {
let url: URL;
try {
url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`);
@@ -244,7 +250,7 @@ export function validateMcpLoopbackRequest(params: {
return null;
}
return { senderIsOwner: sender.senderIsOwner };
return { senderIsOwner: sender.senderIsOwner, boundSessionKey: sender.boundSessionKey };
}
export async function readMcpHttpBody(
@@ -357,8 +363,26 @@ export function resolveMcpCliCaptureKey(req: IncomingMessage): string | undefine
export function resolveMcpRequestContext(
req: IncomingMessage,
cfg: OpenClawConfig,
auth: { senderIsOwner: boolean },
auth: { senderIsOwner: boolean; boundSessionKey?: string },
): McpRequestContext {
// Grant-authenticated callers get only their server-bound session; spoofable
// delivery/action headers stay reserved for the gateway-launched loopback client.
if (auth.boundSessionKey) {
return {
sessionKey: auth.boundSessionKey,
sessionId: undefined,
messageProvider: undefined,
currentChannelId: undefined,
currentThreadTs: undefined,
currentMessageId: undefined,
currentInboundAudio: undefined,
accountId: undefined,
inboundEventKind: undefined,
sourceReplyDeliveryMode: undefined,
requireExplicitMessageTarget: undefined,
senderIsOwner: auth.senderIsOwner,
};
}
return {
sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")),
sessionId: normalizeOptionalString(getHeader(req, "x-openclaw-session-id")),
+38
View File
@@ -102,6 +102,7 @@ vi.mock("./tool-resolution.js", () => ({
resolveGatewayScopedToolsMock(...args),
}));
import { resetAttachGrantsForTest, mintAttachGrant } from "./mcp-grant-store.js";
import {
createMcpLoopbackServerConfig,
closeMcpLoopbackServer,
@@ -724,6 +725,43 @@ describe("mcp loopback server", () => {
]);
});
it("binds an attach grant's session and ignores ALL spoofed context headers (no scope-shop)", async () => {
resetAttachGrantsForTest();
const grant = mintAttachGrant({ sessionKey: "agent:main:attach-host" });
const port = await getFreePortBlockWithPermissionFallback({
offsets: [0],
fallbackBase: 53_000,
});
const { port: serverPort } = await startLoopbackServerForTest(port);
const response = await sendRaw({
port: serverPort,
token: grant.token,
headers: jsonHeaders({
"x-session-key": "agent:main:SPOOFED-other-session",
"x-openclaw-message-channel": "telegram",
"x-openclaw-account-id": "victim-account",
"x-openclaw-current-channel-id": "telegram:victim-chat",
"x-openclaw-current-thread-ts": "999",
"x-openclaw-source-reply-delivery-mode": "automatic",
"x-openclaw-inbound-event-kind": "room_event",
}),
body: mcpToolsListBody(),
});
expect(response.status).toBe(200);
const call = getScopedToolsCall(0);
expect(call.sessionKey).toBe("agent:main:attach-host");
expect(call.senderIsOwner).toBe(false);
expect(call.surface).toBe("loopback");
expect(call.messageProvider).toBeUndefined();
expect(call.accountId).toBeUndefined();
expect(call.currentChannelId).toBeUndefined();
expect(call.currentThreadTs).toBeUndefined();
expect(call.sourceReplyDeliveryMode).toBeUndefined();
expect(call.inboundEventKind).toBeUndefined();
});
it("routes sessions_yield to the current CLI capture", async () => {
resolveGatewayScopedToolsMock.mockImplementation((input): MockGatewayScopedTools => {
const call = input as ScopedToolsCall;
+2
View File
@@ -218,6 +218,8 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
{ name: "poll", scope: "operator.write", advertise: false },
{ name: "sessions.steer", scope: "operator.write", advertise: false },
{ name: "push.test", scope: "operator.write", advertise: false },
{ name: "attach.grant", scope: "operator.admin", controlPlaneWrite: true },
{ name: "attach.revoke", scope: "operator.admin" },
{ name: "push.web.vapidPublicKey", scope: "operator.write", advertise: false },
{ name: "push.web.subscribe", scope: "operator.write", advertise: false },
{ name: "push.web.unsubscribe", scope: "operator.write", advertise: false },
+8
View File
@@ -74,6 +74,10 @@ const loadArtifactsHandlers = lazyHandlerModule(
() => import("./server-methods/artifacts.js"),
(module) => module.artifactsHandlers,
);
const loadAttachHandlers = lazyHandlerModule(
() => import("./server-methods/attach.js"),
(module) => module.attachHandlers,
);
const loadChannelsHandlers = lazyHandlerModule(
() => import("./server-methods/channels.js"),
(module) => module.channelsHandlers,
@@ -271,6 +275,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
methods: ["connect"],
loadHandlers: loadConnectHandlers,
}),
...createLazyCoreHandlers({
methods: ["attach.grant", "attach.revoke"],
loadHandlers: loadAttachHandlers,
}),
...createLazyCoreHandlers({
methods: ["logs.tail"],
loadHandlers: loadLogsHandlers,
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetAttachGrantsForTest, resolveAttachGrant } from "../mcp-grant-store.js";
import { closeMcpLoopbackServer } from "../mcp-http.js";
import { attachHandlers } from "./attach.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const grantOpts = (sessionKey: string, respond: ReturnType<typeof vi.fn>) =>
({
params: { sessionKey },
respond,
context: { getRuntimeConfig: () => ({}) },
}) as unknown as GatewayRequestHandlerOptions;
describe("attach gateway methods", () => {
beforeEach(() => resetAttachGrantsForTest());
afterEach(async () => {
resetAttachGrantsForTest();
// attach.grant lazily starts the loopback singleton; close it so it doesn't leak across files.
await closeMcpLoopbackServer();
});
it("attach.grant mints a session-bound grant and returns loopback config + token env", async () => {
const respond = vi.fn();
await attachHandlers["attach.grant"](grantOpts("agent:main:attach-method", respond));
expect(respond).toHaveBeenCalledTimes(1);
const [ok, payload] = respond.mock.calls[0];
expect(ok).toBe(true);
const body = payload as {
token: string;
sessionKey: string;
mcpConfig: unknown;
env: Record<string, string>;
};
expect(body.sessionKey).toBe("agent:main:attach-method");
expect(body.token).toMatch(/^[0-9a-f]{64}$/);
expect(body.mcpConfig).toBeTruthy();
expect(body.env.OPENCLAW_MCP_TOKEN).toBe(body.token);
expect(body.env.OPENCLAW_MCP_SESSION_KEY).toBe("agent:main:attach-method");
expect(resolveAttachGrant(body.token)?.sessionKey).toBe("agent:main:attach-method");
});
it("attach.revoke removes a grant; missing token is an INVALID_REQUEST", async () => {
const grantRespond = vi.fn();
await attachHandlers["attach.grant"](grantOpts("agent:main:revoke-me", grantRespond));
const token = (grantRespond.mock.calls[0][1] as { token: string }).token;
const revokeRespond = vi.fn();
await attachHandlers["attach.revoke"]({
params: { token },
respond: revokeRespond,
} as unknown as GatewayRequestHandlerOptions);
expect(revokeRespond).toHaveBeenCalledWith(true, { revoked: true });
expect(resolveAttachGrant(token)).toBeUndefined();
const errRespond = vi.fn();
await attachHandlers["attach.revoke"]({
params: {},
respond: errRespond,
} as unknown as GatewayRequestHandlerOptions);
const [errOk, , err] = errRespond.mock.calls[0];
expect(errOk).toBe(false);
expect((err as { code: string }).code).toBe("INVALID_REQUEST");
});
it("applies a positive ttlMs and falls back to the default for an invalid one", async () => {
const r1 = vi.fn();
await attachHandlers["attach.grant"]({
params: { sessionKey: "agent:main:ttl", ttlMs: 30_000 },
respond: r1,
context: { getRuntimeConfig: () => ({}) },
} as unknown as GatewayRequestHandlerOptions);
const now1 = Date.now();
const b1 = r1.mock.calls[0][1] as { expiresAtMs: number };
expect(b1.expiresAtMs).toBeGreaterThan(now1 + 20_000);
expect(b1.expiresAtMs).toBeLessThan(now1 + 40_000); // honored 30s ttl, not the 1h default
const r2 = vi.fn();
await attachHandlers["attach.grant"]({
params: { sessionKey: "agent:main:ttl2", ttlMs: -5 },
respond: r2,
context: { getRuntimeConfig: () => ({}) },
} as unknown as GatewayRequestHandlerOptions);
const b2 = r2.mock.calls[0][1] as { expiresAtMs: number };
expect(b2.expiresAtMs).toBeGreaterThan(Date.now() + 50 * 60_000);
});
it("attach.revoke treats non-object params as a missing token (INVALID_REQUEST)", async () => {
const respond = vi.fn();
await attachHandlers["attach.revoke"]({
params: null,
respond,
} as unknown as GatewayRequestHandlerOptions);
const [ok, , err] = respond.mock.calls[0];
expect(ok).toBe(false);
expect((err as { code: string }).code).toBe("INVALID_REQUEST");
});
});
+60
View File
@@ -0,0 +1,60 @@
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import { resolveMainSessionKey } from "../../config/sessions.js";
import { mintAttachGrant, revokeAttachGrant } from "../mcp-grant-store.js";
import { ensureMcpLoopbackServer } from "../mcp-http.js";
import {
createMcpLoopbackServerConfig,
getActiveMcpLoopbackRuntime,
} from "../mcp-http.loopback-runtime.js";
import type { GatewayRequestHandlers } from "./types.js";
function paramRecord(params: unknown): Record<string, unknown> {
return params && typeof params === "object" ? (params as Record<string, unknown>) : {};
}
function readString(params: Record<string, unknown>, key: string): string | undefined {
const value = params[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readPositiveNumber(params: Record<string, unknown>, key: string): number | undefined {
const value = params[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
export const attachHandlers: GatewayRequestHandlers = {
"attach.grant": async ({ params, respond, context }) => {
const grantParams = paramRecord(params);
await ensureMcpLoopbackServer();
const runtime = getActiveMcpLoopbackRuntime();
if (!runtime) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "mcp loopback server unavailable"),
);
return;
}
const sessionKey =
readString(grantParams, "sessionKey") ?? resolveMainSessionKey(context.getRuntimeConfig());
const grant = mintAttachGrant({ sessionKey, ttlMs: readPositiveNumber(grantParams, "ttlMs") });
respond(true, {
sessionKey: grant.sessionKey,
token: grant.token,
expiresAtMs: grant.expiresAtMs,
mcpConfig: createMcpLoopbackServerConfig(runtime.port),
env: {
OPENCLAW_MCP_TOKEN: grant.token,
OPENCLAW_MCP_SESSION_KEY: grant.sessionKey,
},
});
},
"attach.revoke": async ({ params, respond }) => {
const token = readString(paramRecord(params), "token");
if (!token) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required"));
return;
}
respond(true, { revoked: revokeAttachGrant(token) });
},
};
+320
View File
@@ -516,6 +516,326 @@ describe("talk.config handler", () => {
expectRecordFields(resolved, { provider: "acme" });
expectRecordFields(resolved?.config, { apiKey: "__OPENCLAW_REDACTED__" });
});
it("returns runtime-resolved Talk provider SecretRefs to authorized clients", async () => {
const sourceConfig = createTalkConfig({
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const talkConfig = response.config?.talk;
expectRecordFields(talkConfig, { provider: "acme" });
const providers = talkConfig?.providers as Record<string, unknown> | undefined;
const providerConfig = expectRecordFields(providers?.acme, { voiceId: "stub-default-voice" });
expectRecordFields(providerConfig.apiKey, {
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const resolved = talkConfig?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved, { provider: "acme" });
expectRecordFields(resolved?.config, { apiKey: "runtime-resolved-talk-key" });
});
it("materializes only the active Talk provider apiKey for authorized clients", async () => {
const sourceConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: { source: "env", provider: "default", id: "ACME_SPEECH_API_KEY" },
voiceId: "active-voice",
},
other: {
apiKey: { source: "env", provider: "default", id: "OTHER_SPEECH_API_KEY" },
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: { source: "env", provider: "default", id: "OPENAI_REALTIME_API_KEY" },
voice: "cedar",
},
},
},
},
} as OpenClawConfig;
const runtimeConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: "runtime-active-talk-key",
voiceId: "active-voice",
},
other: {
apiKey: "runtime-inactive-talk-key",
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: "runtime-realtime-key",
voice: "cedar",
},
},
},
},
} as OpenClawConfig;
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const talkConfig = response.config?.talk;
const providers = talkConfig?.providers as Record<string, unknown> | undefined;
expectRecordFields((providers?.acme as Record<string, unknown> | undefined)?.apiKey, {
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
expectRecordFields((providers?.other as Record<string, unknown> | undefined)?.apiKey, {
source: "env",
provider: "default",
id: "OTHER_SPEECH_API_KEY",
});
const realtime = talkConfig?.realtime as Record<string, unknown> | undefined;
const realtimeProviders = realtime?.providers as Record<string, unknown> | undefined;
expectRecordFields((realtimeProviders?.openai as Record<string, unknown> | undefined)?.apiKey, {
source: "env",
provider: "default",
id: "OPENAI_REALTIME_API_KEY",
});
const resolved = talkConfig?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved, { provider: "acme" });
expectRecordFields(resolved?.config, { apiKey: "runtime-active-talk-key" });
const serialized = JSON.stringify(response);
expect(serialized).toContain("runtime-active-talk-key");
expect(serialized).not.toContain("runtime-inactive-talk-key");
expect(serialized).not.toContain("runtime-realtime-key");
});
it("does not expose resolver-returned secret-like fields beyond apiKey", async () => {
const sourceConfig = createTalkConfig({
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
mocks.getSpeechProvider.mockReturnValue({
id: "acme",
label: "Acme Speech",
resolveTalkConfig: ({
talkProviderConfig,
}: {
talkProviderConfig: Record<string, unknown>;
}) => ({
...talkProviderConfig,
voiceId: "resolver-voice",
clientSecret: "resolver-client-secret",
authToken: "resolver-auth-token",
}),
});
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved?.config, {
apiKey: "runtime-resolved-talk-key",
voiceId: "resolver-voice",
clientSecret: "__OPENCLAW_REDACTED__",
authToken: "__OPENCLAW_REDACTED__",
});
const serialized = JSON.stringify(response);
expect(serialized).not.toContain("resolver-client-secret");
expect(serialized).not.toContain("resolver-auth-token");
});
it("does not expose source provider raw keys or secret-like sibling fields", async () => {
const sourceConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: "source-active-talk-key",
voiceId: "active-voice",
clientSecret: "source-client-secret",
},
other: {
apiKey: "source-inactive-talk-key",
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: "source-realtime-key",
authToken: "source-realtime-auth-token",
},
},
},
},
} as OpenClawConfig;
const runtimeConfig = {
talk: {
provider: "acme",
providers: {
acme: {
apiKey: "runtime-active-talk-key",
voiceId: "active-voice",
clientSecret: "runtime-client-secret",
},
other: {
apiKey: "runtime-inactive-talk-key",
voiceId: "inactive-voice",
},
},
realtime: {
provider: "openai",
providers: {
openai: {
apiKey: "runtime-realtime-key",
authToken: "runtime-realtime-auth-token",
},
},
},
},
} as OpenClawConfig;
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: { includeSecrets: true },
client: { connect: { scopes: ["operator.talk.secrets"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved?.config, {
apiKey: "runtime-active-talk-key",
clientSecret: "__OPENCLAW_REDACTED__",
});
const serialized = JSON.stringify(response);
expect(serialized).toContain("runtime-active-talk-key");
expect(serialized).not.toContain("source-active-talk-key");
expect(serialized).not.toContain("source-inactive-talk-key");
expect(serialized).not.toContain("source-realtime-key");
expect(serialized).not.toContain("source-client-secret");
expect(serialized).not.toContain("source-realtime-auth-token");
expect(serialized).not.toContain("runtime-inactive-talk-key");
expect(serialized).not.toContain("runtime-realtime-key");
expect(serialized).not.toContain("runtime-client-secret");
expect(serialized).not.toContain("runtime-realtime-auth-token");
});
it("redacts runtime-resolved Talk provider SecretRefs without Talk secret scope", async () => {
const sourceConfig = createTalkConfig({
source: "env",
provider: "default",
id: "ACME_SPEECH_API_KEY",
});
const runtimeConfig = createTalkConfig("runtime-resolved-talk-key");
mocks.getSpeechProvider.mockReturnValue(undefined);
mocks.readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw.json",
hash: "test-hash",
valid: true,
config: sourceConfig,
});
const respond = vi.fn();
await talkHandlers["talk.config"]({
req: { type: "req", id: "1", method: "talk.config" },
params: {},
client: { connect: { scopes: ["operator.read"] } } as never,
isWebchatConnect: () => false,
respond: respond as never,
context: { getRuntimeConfig: () => runtimeConfig } as never,
});
const response = expectRespondOk(respond) as { config?: { talk?: Record<string, unknown> } };
const resolved = response.config?.talk?.resolved as Record<string, unknown> | undefined;
expectRecordFields(resolved, { provider: "acme" });
const resolvedConfig = expectRecordFields(resolved?.config, {});
expectRecordFields(resolvedConfig.apiKey, {
source: "__OPENCLAW_REDACTED__",
provider: "__OPENCLAW_REDACTED__",
id: "__OPENCLAW_REDACTED__",
});
const serialized = JSON.stringify(response);
expect(serialized).not.toContain("runtime-resolved-talk-key");
expect(serialized).not.toContain("ACME_SPEECH_API_KEY");
});
});
describe("talk.session unified handlers", () => {
+111 -23
View File
@@ -26,7 +26,11 @@ import {
normalizeTalkSection,
resolveActiveTalkProviderConfig,
} from "../../config/talk.js";
import type { TalkConfigResponse, TalkProviderConfig } from "../../config/types.gateway.js";
import type {
TalkConfigResponse,
TalkProviderConfig,
TalkRealtimeConfig,
} from "../../config/types.gateway.js";
import type { OpenClawConfig, TtsConfig, TtsProviderConfigMap } from "../../config/types.js";
import { listRealtimeTranscriptionProviders } from "../../realtime-transcription/provider-registry.js";
import {
@@ -45,6 +49,7 @@ import {
type TtsDirectiveOverrides,
} from "../../tts/tts.js";
import { ADMIN_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js";
import { resolveConfiguredSecretInputString } from "../resolve-configured-secret-input-string.js";
import { formatForLog } from "../ws-log.js";
import { talkClientHandlers } from "./talk-client.js";
import { talkSessionHandlers } from "./talk-session.js";
@@ -396,24 +401,23 @@ function inferMimeType(
return undefined;
}
function resolveTalkResponseFromConfig(params: {
async function resolveTalkResponseFromConfig(params: {
includeSecrets: boolean;
sourceConfig: OpenClawConfig;
runtimeConfig: OpenClawConfig;
}): TalkConfigResponse | undefined {
}): Promise<TalkConfigResponse | undefined> {
const normalizedTalk = normalizeTalkSection(params.sourceConfig.talk);
if (!normalizedTalk) {
return undefined;
}
const payload = buildTalkConfigResponse(normalizedTalk);
if (!payload) {
const sourcePayload = buildTalkConfigResponse(normalizedTalk);
if (!sourcePayload) {
return undefined;
}
if (params.includeSecrets) {
return payload;
}
const payload = params.includeSecrets
? projectTalkSourcePayloadForSecrets(sourcePayload)
: sourcePayload;
const sourceResolved = resolveActiveTalkProviderConfig(normalizedTalk);
const runtimeResolved = resolveActiveTalkProviderConfig(params.runtimeConfig.talk);
@@ -436,15 +440,18 @@ function resolveTalkResponseFromConfig(params: {
Object.keys(runtimeBaseTts).length > 0
? runtimeBaseTts
: stripUnresolvedSecretApiKeysFromBaseTtsProviders(sourceBaseTts);
// Prefer runtime-resolved provider config (already-substituted secrets) and
// fall back to source. Strip any apiKey that is still a SecretRef wrapper —
// provider plugins (ElevenLabs/OpenAI) call strict secret helpers that throw
// on unresolved wrappers, and the discovery path doesn't need the resolved
// value: the response's apiKey is restored from source so the UI keeps the
// SecretRef shape, and redaction strips the value when includeSecrets=false.
const providerInputConfig = stripUnresolvedSecretApiKey(
Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig,
);
// Prefer runtime-resolved provider config and fall back to source. Provider
// plugins (ElevenLabs/OpenAI) call strict secret helpers that throw on
// unresolved wrappers, so only the already-authorized includeSecrets path may
// materialize SecretRef apiKey values before provider resolution. Read-scope
// calls keep the old strip/redact behavior.
const providerInputConfig = await resolveTalkProviderInputConfig({
includeSecrets: params.includeSecrets,
config: params.runtimeConfig,
providerConfig:
Object.keys(runtimeProviderConfig).length > 0 ? runtimeProviderConfig : sourceProviderConfig,
provider,
});
const resolvedConfig =
speechProvider?.resolveTalkConfig?.({
cfg: params.runtimeConfig,
@@ -452,10 +459,11 @@ function resolveTalkResponseFromConfig(params: {
talkProviderConfig: providerInputConfig,
timeoutMs: typeof selectedBaseTts.timeoutMs === "number" ? selectedBaseTts.timeoutMs : 30_000,
}) ?? providerInputConfig;
const responseConfig =
sourceProviderConfig.apiKey === undefined
? resolvedConfig
: { ...resolvedConfig, apiKey: sourceProviderConfig.apiKey };
const responseConfig = projectTalkResolvedProviderConfig({
includeSecrets: params.includeSecrets,
sourceProviderConfig,
resolvedConfig,
});
return {
...payload,
@@ -467,6 +475,86 @@ function resolveTalkResponseFromConfig(params: {
};
}
function projectTalkResolvedProviderConfig(params: {
includeSecrets: boolean;
sourceProviderConfig: TalkProviderConfig;
resolvedConfig: TalkProviderConfig;
}): TalkProviderConfig {
if (!params.includeSecrets) {
return params.sourceProviderConfig.apiKey === undefined
? params.resolvedConfig
: { ...params.resolvedConfig, apiKey: params.sourceProviderConfig.apiKey };
}
// includeSecrets authorizes the active Talk provider key only. Keep resolver
// defaults in the resolved payload, but do not turn arbitrary provider-owned
// secret-like fields into a new native-client credential surface.
const projected = redactConfigObject(params.resolvedConfig);
const apiKey = normalizeOptionalString(params.resolvedConfig.apiKey);
return apiKey === undefined ? projected : { ...projected, apiKey };
}
function projectTalkSourceProviderConfigForSecrets(config: TalkProviderConfig): TalkProviderConfig {
const projected = redactConfigObject(config);
if (config.apiKey === undefined || typeof config.apiKey === "string") {
return projected;
}
return { ...projected, apiKey: config.apiKey };
}
function projectTalkSourceProviderMapForSecrets(
providers: Record<string, TalkProviderConfig> | undefined,
): Record<string, TalkProviderConfig> | undefined {
if (!providers) {
return undefined;
}
return Object.fromEntries(
Object.entries(providers).map(([providerId, providerConfig]) => [
providerId,
projectTalkSourceProviderConfigForSecrets(providerConfig),
]),
);
}
function projectTalkRealtimeForSecrets(realtime: TalkRealtimeConfig): TalkRealtimeConfig {
const projected = redactConfigObject(realtime);
const providers = projectTalkSourceProviderMapForSecrets(realtime.providers);
return providers ? { ...projected, providers } : projected;
}
function projectTalkSourcePayloadForSecrets(payload: TalkConfigResponse): TalkConfigResponse {
const projected = redactConfigObject(payload);
const providers = projectTalkSourceProviderMapForSecrets(payload.providers);
if (providers) {
projected.providers = providers;
}
if (payload.realtime) {
projected.realtime = projectTalkRealtimeForSecrets(payload.realtime);
}
return projected;
}
async function resolveTalkProviderInputConfig(params: {
includeSecrets: boolean;
config: OpenClawConfig;
providerConfig: TalkProviderConfig;
provider: string;
}): Promise<TalkProviderConfig> {
const strippedConfig = stripUnresolvedSecretApiKey(params.providerConfig);
if (!params.includeSecrets || params.providerConfig.apiKey === undefined) {
return strippedConfig;
}
const resolved = await resolveConfiguredSecretInputString({
config: params.config,
env: process.env,
value: params.providerConfig.apiKey,
path: `talk.providers.${params.provider}.apiKey`,
});
return resolved.value === undefined
? strippedConfig
: { ...params.providerConfig, apiKey: resolved.value };
}
function stripUnresolvedSecretApiKey(config: TalkProviderConfig): TalkProviderConfig {
return stripUnresolvedSecretApiKeyFromRecord(config) as TalkProviderConfig;
}
@@ -564,7 +652,7 @@ export const talkHandlers: GatewayRequestHandlers = {
const runtimeConfig = context.getRuntimeConfig();
const configPayload: Record<string, unknown> = {};
const talk = resolveTalkResponseFromConfig({
const talk = await resolveTalkResponseFromConfig({
includeSecrets,
sourceConfig: snapshot.config,
runtimeConfig,
+8 -3
View File
@@ -302,7 +302,8 @@ describe("gateway talk.config", () => {
expect(res.ok).toBe(true);
expectTalkConfig(res.payload?.config?.talk, {
provider: GENERIC_TALK_PROVIDER_ID,
apiKey: "secret-key-abc",
providerApiKey: "__OPENCLAW_REDACTED__",
resolvedApiKey: "secret-key-abc",
});
});
});
@@ -313,7 +314,10 @@ describe("gateway talk.config", () => {
});
await withEnvAsync({ [GENERIC_TALK_API_ENV]: "env-acme-key" }, async () => {
await expectTalkSecretsConfig({ apiKey: talkApiSecretRef() });
await expectTalkSecretsConfig({
providerApiKey: talkApiSecretRef(),
resolvedApiKey: "env-acme-key",
});
});
});
@@ -402,7 +406,8 @@ describe("gateway talk.config", () => {
await expectTalkSecretsConfig({
voiceId: "voice-secretref",
apiKey: talkApiSecretRef(),
providerApiKey: talkApiSecretRef(),
resolvedApiKey: "env-acme-key",
});
},
);
+126
View File
@@ -216,3 +216,129 @@ describe("resolveExecutable", () => {
});
});
});
describe("caller env PATHEXT propagation", () => {
// These tests verify that isExecutableFile and its callers use the
// caller-provided env.PATHEXT (not just process.env.PATHEXT) on Windows.
// Regression for: isExecutableFile hardcoded undefined -> ignore caller env.
it("isExecutableFile respects caller env.PATHEXT on Windows", async () => {
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
try {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const ps1File = path.join(base, "script.ps1");
await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8");
// On Windows with only .PS1 in caller env, isExecutableFile should accept it
withMockedPlatform("win32", () => {
expect(isExecutableFile(ps1File, { env: { PATHEXT: ".PS1" } })).toBe(true);
});
});
} finally {
restoreEnvValue("PATHEXT", orig);
}
});
it("isExecutableFile fallback to process.env when no caller env is given", async () => {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const ps1File = path.join(base, "script.ps1");
await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8");
// Save current process.env.PATHEXT, set it to empty so no extension matches
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
try {
withMockedPlatform("win32", () => {
// .PS1 not in process.env.PATHEXT (which is .TXT)
expect(isExecutableFile(ps1File)).toBe(false);
});
} finally {
restoreEnvValue("PATHEXT", orig);
}
});
});
it("resolveExecutableFromPathEnv uses caller env PATHEXT on Windows", async () => {
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
try {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const binDir = path.join(base, "bin");
await fs.mkdir(binDir, { recursive: true });
const ps1Path = path.join(binDir, "tool.ps1");
await fs.writeFile(ps1Path, 'Write-Output "ok"\n', "utf8");
// On Windows the delimiter is ";", build pathEnv accordingly for mocked platform
const pathEnv = `${binDir};${process.env.PATH ?? ""}`;
withMockedPlatform("win32", () => {
// Caller env has .PS1, process.env.PATHEXT does not
const result = resolveExecutableFromPathEnv("tool", pathEnv, { PATHEXT: ".PS1" });
expect(result).toBe(ps1Path);
});
});
} finally {
restoreEnvValue("PATHEXT", orig);
}
});
it("resolveExecutablePath with path separator passes env to PATHEXT check on Windows", async () => {
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
try {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const ps1File = path.join(base, "script.ps1");
await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8");
withMockedPlatform("win32", () => {
// Passing an absolute path (has separator) with custom env
const result = resolveExecutablePath(ps1File, { env: { PATHEXT: ".PS1" } });
expect(result).toBe(ps1File);
});
});
} finally {
restoreEnvValue("PATHEXT", orig);
}
});
it("resolveExecutablePath without path separator falls back to PATH env", async () => {
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
try {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const binDir = path.join(base, "bin");
await fs.mkdir(binDir, { recursive: true });
const ps1Path = path.join(binDir, "runner.ps1");
await fs.writeFile(ps1Path, 'Write-Output "ok"\n', "utf8");
withMockedPlatform("win32", () => {
const result = resolveExecutablePath("runner", {
env: { PATH: binDir, PATHEXT: ".PS1" },
});
expect(result).toBe(ps1Path);
});
});
} finally {
restoreEnvValue("PATHEXT", orig);
}
});
it("resolveExecutablePath with path separator falls back to process.env when no caller env given", async () => {
await withTempDir({ prefix: "openclaw-exec-path-" }, async (base) => {
const ps1File = path.join(base, "script.ps1");
await fs.writeFile(ps1File, 'Write-Output "ok"\n', "utf8");
const orig = process.env.PATHEXT;
process.env.PATHEXT = ".TXT";
try {
withMockedPlatform("win32", () => {
// No caller env given, process.env.PATHEXT is .TXT -> .PS1 not matched -> undefined
expect(resolveExecutablePath(ps1File)).toBeUndefined();
});
} finally {
restoreEnvValue("PATHEXT", orig);
}
});
});
});
+9 -5
View File
@@ -46,8 +46,10 @@ function resolveWindowsExecutableExtensions(
"",
...(
env?.PATHEXT ??
env?.PathExt ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.PathExt ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
@@ -60,8 +62,10 @@ function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set
return new Set(
(
env?.PATHEXT ??
env?.PathExt ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.PathExt ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
@@ -71,7 +75,7 @@ function resolveWindowsExecutableExtSet(env: NodeJS.ProcessEnv | undefined): Set
);
}
export function isExecutableFile(filePath: string): boolean {
export function isExecutableFile(filePath: string, options?: { env?: NodeJS.ProcessEnv }): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) {
@@ -82,7 +86,7 @@ export function isExecutableFile(filePath: string): boolean {
if (!ext) {
return true;
}
return resolveWindowsExecutableExtSet(undefined).has(ext);
return resolveWindowsExecutableExtSet(options?.env).has(ext);
}
fs.accessSync(filePath, fs.constants.X_OK);
return true;
@@ -102,7 +106,7 @@ export function resolveExecutableFromPathEnv(
for (const entry of entries) {
for (const ext of extensions) {
const candidate = path.join(entry, executable + ext);
if (isExecutableFile(candidate)) {
if (isExecutableFile(candidate, { env })) {
return candidate;
}
}
@@ -119,7 +123,7 @@ export function resolveExecutablePath(
return undefined;
}
if (candidate.includes("/") || candidate.includes("\\")) {
return isExecutableFile(candidate) ? candidate : undefined;
return isExecutableFile(candidate, options) ? candidate : undefined;
}
const envPath =
options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
@@ -148,7 +152,7 @@ export function resolveExecutable(cmd: string): string {
for (const entry of entries) {
for (const ext of extensions) {
const candidate = path.join(entry, cmd + ext);
if (isExecutableFile(candidate)) {
if (isExecutableFile(candidate, { env: process.env })) {
matches.push(candidate);
}
}
+41
View File
@@ -0,0 +1,41 @@
// Tests for SQLite number normalization.
import { describe, expect, it } from "vitest";
import { normalizeSqliteNumber } from "./sqlite-number.js";
describe("normalizeSqliteNumber", () => {
it("returns number value unchanged", () => {
expect(normalizeSqliteNumber(5)).toBe(5);
});
it("converts bigint to number", () => {
expect(normalizeSqliteNumber(BigInt(5))).toBe(5);
});
it("returns undefined for null", () => {
expect(normalizeSqliteNumber(null)).toBeUndefined();
});
it("returns zero unchanged", () => {
expect(normalizeSqliteNumber(0)).toBe(0);
});
it("returns negative value unchanged", () => {
expect(normalizeSqliteNumber(-1)).toBe(-1);
});
it("returns NaN unchanged", () => {
expect(normalizeSqliteNumber(Number.NaN)).toBe(Number.NaN);
});
it("converts large bigint", () => {
expect(normalizeSqliteNumber(BigInt(Number.MAX_SAFE_INTEGER))).toBe(Number.MAX_SAFE_INTEGER);
});
it("converts zero bigint", () => {
expect(normalizeSqliteNumber(BigInt(0))).toBe(0);
});
it("converts negative bigint", () => {
expect(normalizeSqliteNumber(BigInt(-1))).toBe(-1);
});
});
+8 -1
View File
@@ -375,7 +375,14 @@ function resolveExecutable(bin: string, env?: Record<string, string>) {
}
const extensions =
process.platform === "win32"
? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM")
? (
env?.PATHEXT ??
env?.PathExt ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.PathExt ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => normalizeLowercaseStringOrEmpty(ext))
: [""];
+2
View File
@@ -50,6 +50,8 @@ export type ReasoningReplyPayload = {
text?: string;
/** Explicit reasoning flag from upstream payload producers. */
isReasoning?: boolean;
/** Marks pre-tool commentary (💬) — a display lane, suppressed unless the channel opts in. */
isCommentary?: boolean;
};
/** Derived sendability facts for text/media outbound payload delivery. */
+2 -2
View File
@@ -222,12 +222,12 @@ export function buildRealtimeVoiceAgentConsultPrompt(params: {
/** Collect only visible answer text from streamed delegated-agent payloads. */
export function collectRealtimeVoiceAgentConsultVisibleText(
payloads: Array<{ text?: unknown; isError?: boolean; isReasoning?: boolean }>,
payloads: Array<{ text?: unknown; isError?: boolean; isReasoning?: boolean; isCommentary?: boolean }>,
): string | null {
const chunks: string[] = [];
for (const payload of payloads) {
// Spoken replies must not include hidden reasoning or error-channel text.
if (payload.isError || payload.isReasoning) {
if (payload.isError || payload.isReasoning || payload.isCommentary) {
continue;
}
const text = normalizeOptionalString(payload.text);
+16 -3
View File
@@ -346,11 +346,24 @@ describe("resolveInitialTuiAgentId", () => {
});
describe("resolveGatewayDisconnectState", () => {
it("returns pairing recovery guidance when disconnect reason requires pairing", () => {
it("returns scope-upgrade recovery guidance when disconnect reason requires pairing", () => {
const state = resolveGatewayDisconnectState("gateway closed (1008): pairing required");
expect(state.connectionStatus).toContain("pairing required");
expect(state.activityStatus).toBe("pairing required: run openclaw devices list");
expect(state.pairingHint).toContain("openclaw devices list");
expect(state.activityStatus).toBe("device approval needed: preview latest request");
expect(state.pairingHint).toContain("openclaw devices approve --latest");
expect(state.pairingHint).toContain("openclaw devices approve <requestId>");
expect(state.pairingHint).toContain("--token");
// Must steer users to `devices`, not the unrelated chat-DM `pairing` command.
expect(state.pairingHint).not.toContain("openclaw pairing");
});
it("returns the same guidance when the gateway reports a pending scope upgrade", () => {
const state = resolveGatewayDisconnectState(
"gateway closed (1008): scope upgrade pending approval",
);
expect(state.activityStatus).toBe("device approval needed: preview latest request");
expect(state.pairingHint).toContain("openclaw devices approve --latest");
expect(state.pairingHint).toContain("openclaw devices approve <requestId>");
});
it("falls back to idle for generic disconnect reasons", () => {
+6 -3
View File
@@ -230,12 +230,15 @@ export function resolveGatewayDisconnectState(reason?: string): {
pairingHint?: string;
} {
const reasonLabel = reason?.trim() ? reason.trim() : "closed";
if (/pairing required/i.test(reasonLabel)) {
// Covers both "pairing required" and a pending "scope upgrade" for a paired device.
if (/pairing required|scope upgrade/i.test(reasonLabel)) {
return {
connectionStatus: `gateway disconnected: ${reasonLabel}`,
activityStatus: "pairing required: run openclaw devices list",
activityStatus: "device approval needed: preview latest request",
pairingHint:
"Pairing required. Run `openclaw devices list`, approve your request ID, then reconnect.",
"Device approval needed. Run `openclaw devices approve --latest` to preview the pending request, " +
"then rerun the printed `openclaw devices approve <requestId>` command " +
"(reuse `--token` or other auth flags if needed), then reconnect.",
};
}
return {
+51
View File
@@ -0,0 +1,51 @@
// Chunk items tests cover fixed-size array splitting edge cases.
import { describe, expect, it } from "vitest";
import { chunkItems } from "./chunk-items.js";
describe("chunkItems", () => {
it("splits items into fixed-size chunks", () => {
expect(chunkItems([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
expect(chunkItems(["a", "b", "c", "d"], 3)).toEqual([["a", "b", "c"], ["d"]]);
});
it("returns a single chunk when size is 0 or negative", () => {
expect(chunkItems([1, 2, 3], 0)).toEqual([[1, 2, 3]]);
expect(chunkItems([1, 2, 3], -1)).toEqual([[1, 2, 3]]);
});
it("returns empty array for empty input", () => {
expect(chunkItems([], 5)).toEqual([]);
expect(chunkItems([], 0)).toEqual([[]]);
expect(chunkItems([], -1)).toEqual([[]]);
});
it("wraps each item when size is 1", () => {
expect(chunkItems([1, 2, 3], 1)).toEqual([[1], [2], [3]]);
});
it("returns a single chunk when size exceeds array length", () => {
expect(chunkItems([1, 2, 3], 10)).toEqual([[1, 2, 3]]);
});
it("exactly divides when size matches array length", () => {
expect(chunkItems([1, 2, 3], 3)).toEqual([[1, 2, 3]]);
});
it("preserves readonly input without mutation", () => {
const input: readonly number[] = [10, 20, 30, 40];
const result = chunkItems(input, 2);
expect(result).toEqual([
[10, 20],
[30, 40],
]);
expect(input).toEqual([10, 20, 30, 40]);
});
it("handles fractional size using slice native truncation behavior", () => {
// slice() truncates non-integer args, so 2.5 → chunk boundaries at 0, 2.5, 5.0
expect(chunkItems([1, 2, 3, 4, 5], 2.5)).toEqual([
[1, 2],
[3, 4, 5],
]);
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Real behavior proof: Twilio malformed JSON graceful handling.
*
* Calls listTwilioIncomingPhoneNumbers and retrieveTwilioMessagingService
* with a custom fetchImpl that returns malformed JSON, exercising the actual
* changed try/catch guards in parseTwilioListPayload and
* retrieveTwilioMessagingService. No vitest or vi.mock required.
*
* Usage: node --import tsx test/_proof_twilio_json_guard.mts
*/
let pass = 0;
let fail = 0;
function check(label: string, ok: boolean, detail = "") {
if (ok) { pass++; console.log(`PASS ${label}${detail ? ` :: ${detail}` : ""}`); }
else { fail++; console.error(`FAIL ${label}${detail ? ` :: ${detail}` : ""}`); }
}
async function proofListPayloadMalformed() {
const { listTwilioIncomingPhoneNumbers } = await import(
"../extensions/sms/src/twilio.js"
);
// Custom fetchImpl returning malformed JSON
const badFetch = async () =>
new Response("NOT JSON {{{", {
status: 200,
headers: { "content-type": "application/json" },
});
const result = await listTwilioIncomingPhoneNumbers({
account: { accountSid: "AC-proof", authToken: "proof-token", fromNumber: "+15550001111" },
fetchImpl: badFetch as typeof fetch,
});
check("listPayload malformed: returns [] (fail-safe)",
Array.isArray(result) && result.length === 0,
`result=${JSON.stringify(result)}`);
}
async function proofMessagingServiceMalformed() {
const { retrieveTwilioMessagingService } = await import(
"../extensions/sms/src/twilio.js"
);
const badFetch = async () =>
new Response("NOT JSON {{{", {
status: 200,
headers: { "content-type": "application/json" },
});
let error: unknown;
try {
await retrieveTwilioMessagingService({
account: { accountSid: "AC-proof", authToken: "proof-token", fromNumber: "+15550001111" },
serviceSid: "MG-proof",
fetchImpl: badFetch as typeof fetch,
});
check("messagingService malformed: throws", false, "should have thrown");
} catch (err: unknown) {
check("messagingService malformed: throws Error", err instanceof Error,
`type=${err instanceof Error ? err.constructor.name : String(err)}`);
if (err instanceof Error) {
check("messagingService malformed: mentions malformed JSON",
err.message.includes("malformed JSON"),
`msg="${err.message}"`);
}
}
}
async function proofListPayloadValid() {
const { listTwilioIncomingPhoneNumbers } = await import(
"../extensions/sms/src/twilio.js"
);
const goodFetch = async () =>
new Response(
JSON.stringify({
incoming_phone_numbers: [
{
sid: "PN123",
phone_number: "+15550009999",
friendly_name: "Test",
sms_url: "https://example.com/webhook",
sms_method: "POST",
capabilities: { sms: true, mms: false },
},
],
page: 0,
page_size: 50,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const result = await listTwilioIncomingPhoneNumbers({
account: { accountSid: "AC-proof", authToken: "proof-token", fromNumber: "+15550001111" },
fetchImpl: goodFetch as typeof fetch,
});
check("listPayload valid: parsed correctly",
result.length === 1 && result[0]?.sid === "PN123",
`count=${result.length} sid=${result[0]?.sid}`);
}
async function main() {
console.log(`node --import tsx test/_proof_twilio_json_guard.mts\n`);
await proofListPayloadMalformed();
await proofMessagingServiceMalformed();
await proofListPayloadValid();
console.log(`\n[proof] ${pass} PASS, ${fail} FAIL`);
if (fail > 0) process.exit(1);
}
main();
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:03.664Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:07:19.847Z",
"locale": "ar",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -7,6 +7,7 @@
{"cache_key":"1b701a3f34630b2c5cf2026b3ca35588ad3a76f0999436f126779e39c51e420d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifacts","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Artifacts","text_hash":"314ae71b8c8dc9c952f0ffc58e35e6d9a41b5cf4756471c7cab0c9476cd5d20b","tgt_lang":"ar","translated":"العناصر","updated_at":"2026-06-16T14:15:41.761Z"}
{"cache_key":"1bb67bd03a5259003cb18979044f40ee96d0b50a449e65065f33ddb5ba15a1fa","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterUnassignedHelp","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Cards without an explicit agent.","text_hash":"f716a36252b33511df056fe7d1092be598eca17ea76bedc5d6d3532ec6b0ffea","tgt_lang":"ar","translated":"البطاقات بدون وكيل محدد.","updated_at":"2026-06-17T14:14:58.728Z"}
{"cache_key":"1de1f729171e18967f76a207f15eaca44b44fa087edb9eb78548516b09ff2197","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"ar","translated":"{count} عنصرًا","updated_at":"2026-06-16T14:15:41.761Z"}
{"cache_key":"20b2e88b33793df99629daf65b657cb666cb9b4a71ea30e2c05f943939c49565","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"ar","translated":"التعليق","updated_at":"2026-07-01T01:07:19.841Z"}
{"cache_key":"20f98bac595afb3715bf5acbd1e087d08570280150e8d7e0649e378a98ac0489","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.noSearchResults","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"No matching files.","text_hash":"6ba2ede6c6019b640f63e7e48c5ee8238e701c6e539ce9abb5a7a9d9c71d8a73","tgt_lang":"ar","translated":"لا توجد ملفات مطابقة.","updated_at":"2026-06-16T14:15:41.761Z"}
{"cache_key":"222a52155c00d9125472c178e688a19fba317aa89a0fabcd3e6a77c0b930f30c","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventProtocolViolation","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Protocol violation","text_hash":"367bb2052963f7d75beb672d3ca0430d7d49ac48a2759d578c7df933178fe564","tgt_lang":"ar","translated":"انتهاك البروتوكول","updated_at":"2026-05-30T15:38:27.116Z"}
{"cache_key":"28628884bf42001bb1106e9a421a05631fa1606a126aa1769ff909067eb3dfd8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.layoutComfortable","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Comfortable card density","text_hash":"bfaaf4553fd254bf24431ebabf62faebfd862685e9e7a52f5e799b11488dc7fe","tgt_lang":"ar","translated":"كثافة بطاقات مريحة","updated_at":"2026-06-17T14:14:58.728Z"}
@@ -36,6 +37,7 @@
{"cache_key":"732ca472029046ca18ebf8398747ced1c60994e3c2a0555b164aa3a52730691b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewBlocked","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Blocked","text_hash":"18f2a0947f9d6523991b29b450307f22773f57d65f7efb98d48a167df04d6b1d","tgt_lang":"ar","translated":"محظور","updated_at":"2026-06-17T14:14:58.728Z"}
{"cache_key":"73a457b2179107dd5d1426e0fec360f1cf9f00ec5ed7b3e77594575ef9660ad0","model":"gpt-5.5","provider":"openai","segment_id":"languages.hi","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"हिन्दी (Hindi)","text_hash":"fab2abfce45382f3031c59477017700a8cb5dfaf8d15379dc24304809b97c7d5","tgt_lang":"ar","translated":"الهندية (Hindi)","updated_at":"2026-06-26T21:43:32.403Z"}
{"cache_key":"74cc623b957f70b35f6fa003d2b2f6cf8343bfa56087b0a673520cc111d226b6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"ar","translated":"جميع البطاقات","updated_at":"2026-06-17T14:14:58.728Z"}
{"cache_key":"7861b489b6754a2b4e68f72ce2d3c1c3a539bb8e2181026222020d021f025c72","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"ar","translated":"الاحتفاظ بالتعليق بعد الإجابة النهائية","updated_at":"2026-07-01T01:07:19.841Z"}
{"cache_key":"7d60d8554dcfb8d41137e8314ecceeffee430377085a746514a22d59c6140240","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"ar","translated":"مساحة العمل: {workspace}","updated_at":"2026-06-16T14:15:27.039Z"}
{"cache_key":"7e044004d8cce1e8fd0122556a521aa760fe712e2c33164c9e8ca17cbbcc2d25","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewPresetCount","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"{count} cards","text_hash":"4b3e5442ebd2f839d45fddf95b2c2a18427dbd6ac06c8b57f9d9e996dcb73607","tgt_lang":"ar","translated":"{count} بطاقة","updated_at":"2026-06-17T14:14:58.728Z"}
{"cache_key":"7f4bc10589146ec18ab97233184abe2b6e5b31524437faf90919e5196a962ae4","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeWorkerLogs","source_path":"ui/src/i18n/locales/ar.ts","src_lang":"en","text":"{count} worker logs","text_hash":"2923e2a84e6ed0ca048d280206198b156da6148859e474498d335a44e323e0a8","tgt_lang":"ar","translated":"{count} سجلات العامل","updated_at":"2026-05-30T15:38:27.116Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:03.027Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:06:02.814Z",
"locale": "de",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -8,6 +8,7 @@
{"cache_key":"15794c3615bcfc57208f237c781c4012d7f3f6208b565699e32f05e1d50af7e2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerLogs","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Worker logs","text_hash":"67766b9f222a7ccdae6beb7d6e7877d1a13abb8a346a8c5c803a4380bdf851b1","tgt_lang":"de","translated":"Worker-Protokolle","updated_at":"2026-06-16T14:12:53.111Z"}
{"cache_key":"167ace856862c782b3dc9dad34349a19fe2e1a840b9e337d430a4603e4defdcf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.label","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Session workspace","text_hash":"c0e8ea0cf983d14e8ba3f8fc28976954d637fd50f807c69e9695715fd2384b78","tgt_lang":"de","translated":"Sitzungsarbeitsbereich","updated_at":"2026-06-16T14:12:59.821Z"}
{"cache_key":"181665fcb0f4b339a06f80616b06f736765b8a46fd4d2483d733f4dac57ec9e0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.missing","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"de","translated":"Fehlt","updated_at":"2026-06-16T14:13:06.672Z"}
{"cache_key":"1a1ff88c753da1e0b553d1ee4be8bb037a254c4a942b60ea839bd775774c26df","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"de","translated":"Kommentar","updated_at":"2026-07-01T01:06:02.805Z"}
{"cache_key":"1b2a8d28defa2a445b54fde09adb8e7647cd5fa790d833f050b5c21386730c71","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"de","translated":"{count} Artefakte","updated_at":"2026-06-16T14:13:06.672Z"}
{"cache_key":"1d4bb403c759a82cef8ee1177c6d355e076966e1a95b8e6661e10f43e826e827","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.empty","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"No files touched in this session yet","text_hash":"6b295e4e11bcdd52340c4cc7987565f848cf477a1f0b96c0f47a2b718418298e","tgt_lang":"de","translated":"In dieser Sitzung wurden noch keine Dateien bearbeitet","updated_at":"2026-06-16T14:12:59.821Z"}
{"cache_key":"1d9a4877504f1fa0218779ff26c43cf16734138e73e01df52a22eb2733c8f0d2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"de","translated":"Läuft","updated_at":"2026-06-17T14:13:07.391Z"}
@@ -37,6 +38,7 @@
{"cache_key":"51a04cef539b3328516dfc9101a46f5cce2e2921f8b19549b4144c763758400f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh30s","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"30s","text_hash":"d3382a4f0e03f8b14cf99424376886c236f1503d4b332137667484fc96d58fc4","tgt_lang":"de","translated":"30s","updated_at":"2026-06-17T14:13:12.251Z"}
{"cache_key":"563460816d3948dc9e063429950fd02b7f97ccd41ea95b4f82aef2639ca3c805","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationSkills","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Skills: {skills}","text_hash":"4788d5f9db66e1421a762bbd942c64450c73d2145a6ef929ce32a919a0f2e3a1","tgt_lang":"de","translated":"Skills: {skills}","updated_at":"2026-06-16T14:12:53.111Z"}
{"cache_key":"57473c8a09374714eceaf4b6a43a37ce1d388c4b12e5d78f1d569698095651fb","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.truncated","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Showing the first matching files. Refine the search to narrow results.","text_hash":"62005877ff0fc1f73ce05ca4c459157c57a8c57a3443245b1df4d3b033df98e9","tgt_lang":"de","translated":"Es werden die ersten passenden Dateien angezeigt. Verfeinern Sie die Suche, um die Ergebnisse einzugrenzen.","updated_at":"2026-06-16T14:13:06.672Z"}
{"cache_key":"5786703813168994fba14e4dff988d5692e5e29a64cf341eb483932fe9d3e06e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"de","translated":"Kommentar nach der endgültigen Antwort behalten","updated_at":"2026-07-01T01:06:02.805Z"}
{"cache_key":"59a3e874dc9b1299daf4a2644ec2659a6e9c50587c91ac357f24215c16b94169","model":"claude-opus-4-8","provider":"anthropic","segment_id":"cron.jobDetail.cwd","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"de","translated":"CWD","updated_at":"2026-06-16T14:13:08.672Z"}
{"cache_key":"5b0ab59138fd65f7f3a236db3c04458d74c99539c8ab736086c9637395c06d55","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.expand","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"Expand session workspace","text_hash":"ac1d210db40c5026879774849ad74a9e1247523192a795ac33965b3ee72691c2","tgt_lang":"de","translated":"Sitzungsarbeitsbereich aufklappen","updated_at":"2026-06-16T14:12:59.821Z"}
{"cache_key":"5f60d04e50be0e22c14d761f808667c2dfd5623e67247c367c1d280613bcffca","model":"gpt-5.5","provider":"openai","segment_id":"workboard.badgeWorkerLogs","source_path":"ui/src/i18n/locales/de.ts","src_lang":"en","text":"{count} worker logs","text_hash":"2923e2a84e6ed0ca048d280206198b156da6148859e474498d335a44e323e0a8","tgt_lang":"de","translated":"{count} Worker-Protokolle","updated_at":"2026-05-30T15:38:11.508Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:03.131Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:06:39.921Z",
"locale": "es",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -20,6 +20,7 @@
{"cache_key":"24a6de859b0956a90013999bf3dba224d13e795496ea46fae64faed2a199e17a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.refresh","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Refresh session workspace","text_hash":"c7a97b20a3a3ce348239c4893c99f1902d44877567cb32f752c30cdfbc9a2468","tgt_lang":"es","translated":"Actualizar espacio de trabajo de la sesión","updated_at":"2026-06-16T14:14:17.778Z"}
{"cache_key":"27fa89c61adc150babc3ded34fdb5864d9ae99d19226958fe7ea11b563e69377","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.summary","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Session workspace summary","text_hash":"1ed422c34dc1802d4c7366164ae810c496e206fe82e8e6565cefc38230b56bb4","tgt_lang":"es","translated":"Resumen del espacio de trabajo de la sesión","updated_at":"2026-06-16T14:14:23.912Z"}
{"cache_key":"281e153d782e5cddfb08ab96830e0df2c057cac4b6ed26c0240247b35b39e675","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailRun","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Run","text_hash":"00d60e31a4e6b8344d4201f25a6a7dee770713107f6d097abb01559d32b17f26","tgt_lang":"es","translated":"Ejecución","updated_at":"2026-06-16T14:14:11.214Z"}
{"cache_key":"2a12488d59c8ed57ec0d57bacf354486376a131687975f7866c863a04cfcd6b5","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"es","translated":"Mantener los comentarios después de la respuesta final","updated_at":"2026-07-01T01:06:39.912Z"}
{"cache_key":"2bc6ac4dce823273f77abe66c28e3e51e6d814f2ee44d288d3f55ef35a4e5656","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChat","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Use current chat","text_hash":"fbc1ffd63daa506e927c7a85f6e43acd11e0b8c9f52a3951fc782b236ce9a787","tgt_lang":"es","translated":"Usar el chat actual","updated_at":"2026-06-16T14:14:11.214Z"}
{"cache_key":"2f707cdc5fe0e4fc554806b712d898b35b2b1325ca6aebddfde0a0aee28905f8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesReady","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"{count} ready","text_hash":"f5f5fd424d7c18f19a51ee147857efddc320a0ec6e1eeb4354be129425632f05","tgt_lang":"es","translated":"{count} listas","updated_at":"2026-06-16T14:14:17.778Z"}
{"cache_key":"30934b4ae099ddda1d297ca871fb752e63f1606ac20f87b5a0a68d470aa1204b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomation","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Automation","text_hash":"d909750b1bbb71a39b6330ba8f81f4f8f6e889ed96d7ab366e74857909750c64","tgt_lang":"es","translated":"Automatización","updated_at":"2026-06-16T14:14:11.215Z"}
@@ -43,6 +44,7 @@
{"cache_key":"51393930b033f6a7a118fcc23fca6d247bf5c89c0d71c9366dde265e4b8a6630","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatAria","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Use current chat for revision requests","text_hash":"9c551a423ae74aedaaa90e4df9899dbdc02f846d6ee058bf2576a812e2c52119","tgt_lang":"es","translated":"Usar el chat actual para las solicitudes de revisión","updated_at":"2026-06-16T14:14:11.214Z"}
{"cache_key":"51d0fe0c003bc4f1c282463c6e237c6fa411e90d0dc6046e0a57014bce6af45f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"es","translated":"Todas las tarjetas","updated_at":"2026-06-17T14:14:08.769Z"}
{"cache_key":"5374b6991e3457597aa34c1ac94740da287f12cb2241f61c42acbfee762a8997","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifacts","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Artifacts","text_hash":"314ae71b8c8dc9c952f0ffc58e35e6d9a41b5cf4756471c7cab0c9476cd5d20b","tgt_lang":"es","translated":"Artefactos","updated_at":"2026-06-16T14:14:23.912Z"}
{"cache_key":"53a39119b041c8b526ef9cf2fae89094f1299c8f5e3772df4dab61042c3f4518","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"es","translated":"Comentarios","updated_at":"2026-07-01T01:06:39.912Z"}
{"cache_key":"58020a74f665c649c802f71d76b737c7d876b96fd95acbe932a5a73db85a1a6d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewMissingProof","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Missing proof","text_hash":"b46debe888e32eec183dc5936c79d22ea43bec580c410c2b3c1aa24aaa75d677","tgt_lang":"es","translated":"Sin comprobante","updated_at":"2026-06-17T14:14:08.769Z"}
{"cache_key":"5d3b656957833268e13752ae570040470f719d92fc1d45948503ac0bf86e3eef","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthFailedAttempts","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"failed attempts","text_hash":"fd9023af0795825a458100ddbe894a7a8f603324a2b7ad2305d4c9d2334cbd26","tgt_lang":"es","translated":"intentos fallidos","updated_at":"2026-06-17T14:14:13.986Z"}
{"cache_key":"5fa5f353c2dd6b0a8085e1f7e44c161a8ce5765c6cfea51dd771218c6123ce2f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationTenant","source_path":"ui/src/i18n/locales/es.ts","src_lang":"en","text":"Tenant: {tenant}","text_hash":"e896dc96a6847d7aaa593069e890e7a712fd60d7be60280ee24e1942e10411b0","tgt_lang":"es","translated":"Tenant: {tenant}","updated_at":"2026-06-16T14:14:11.215Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:04.632Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:08:55.391Z",
"locale": "fa",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -15,6 +15,7 @@
{"cache_key":"27bddaa81a39838a7db147c71a1cf70ecaf8f77fdb02dc4044429510d81a2af8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.workspace","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"fa","translated":"نشست","updated_at":"2026-06-16T14:18:40.803Z"}
{"cache_key":"288b7d325a0a012c988365dc4f25865247bd4e9ad36b28fe64602aa592730e03","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"fa","translated":"۶۰ث","updated_at":"2026-06-17T14:17:52.321Z"}
{"cache_key":"2a0c4156e119cbbee60b0fcd324e244a6a5d929ee42825aa597fb1bc0d96689a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyMissing","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"{parent} (missing)","text_hash":"8daa419059727391c01e3b7021e05d8d70b4da67f9c57cd2d80f302af77aac53","tgt_lang":"fa","translated":"{parent} (موجود نیست)","updated_at":"2026-06-16T14:18:40.803Z"}
{"cache_key":"2a518a5678410988058a5f5bb49f7a7b252650d0ca7de4c6f5a5d6d15a3a1315","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"fa","translated":"توضیحات","updated_at":"2026-07-01T01:08:55.386Z"}
{"cache_key":"2d11072c42f933f7842f3cceb8de9f651f2b179dd0c953f0f596df8b1664bf83","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailUpdated","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Updated","text_hash":"3a5ecca188c0579c00ee24cf3cab21bd02c15a06f7a70cc8e0a8ff2381dcbbfd","tgt_lang":"fa","translated":"به‌روزرسانی شده","updated_at":"2026-06-16T14:18:33.052Z"}
{"cache_key":"2fd4392dfaf76c78038a7ddd7fd6dced4b6ec2dbed11056e095fe62fd076c629","model":"claude-opus-4-8","provider":"anthropic","segment_id":"cron.jobDetail.cwd","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"CWD","text_hash":"0217f1cb7725737f15a6710df3bcfa3bc10a239f0f7801ec3d7168e675f5ebd6","tgt_lang":"fa","translated":"CWD","updated_at":"2026-06-16T14:18:51.277Z"}
{"cache_key":"3043c3a4d9b51e83d569074ad899dbf4bd1a12653772146156a62646579a936b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthRunning","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"running","text_hash":"c071cf5f5ed6f884cc70155b6f05f755fd46a302d05e4261b7e92ce878bbfed8","tgt_lang":"fa","translated":"در حال اجرا","updated_at":"2026-06-17T14:17:52.321Z"}
@@ -34,6 +35,7 @@
{"cache_key":"4f05e9e5fbf924ac2f46d0911f2a6cb352db867595de5e9f02fa06e83f15e8bf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.emptyFilteredHint","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Change the view, search, priority, agent, or archive filter.","text_hash":"049dfae940263ace9707334af06b298c1223c38a449b1cec5a712553badebbd0","tgt_lang":"fa","translated":"نما، جست‌وجو، اولویت، عامل یا فیلتر بایگانی را تغییر دهید.","updated_at":"2026-06-17T14:17:52.321Z"}
{"cache_key":"5041238e315bbd454dc532fba706052bdb36409f20e76ee2f5990aacacb0bb5d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"fa","translated":"فضای کاری: {workspace}","updated_at":"2026-06-16T14:18:33.052Z"}
{"cache_key":"52148bd591542bf023502ed432ff714e900777dc4091852c878236b87f216a5b","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.badgeHeartbeat","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"heartbeat {age}","text_hash":"000637b3800ae069edbbe207cfad0a3f5037f06e9661ee89d70a1dfe6f404485","tgt_lang":"fa","translated":"ضربان {age}","updated_at":"2026-06-17T14:17:52.321Z"}
{"cache_key":"52c71ec0fa1f6d7d7852134c41e334b08dd2785e3400941fb4c6d58347a3b3c6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"fa","translated":"حفظ توضیحات پس از پاسخ نهایی","updated_at":"2026-07-01T01:08:55.386Z"}
{"cache_key":"540cc2e8155aae8b1273f29ebbc09c53a7c4789810c7d914400b0dc22757b6cf","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.session","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"fa","translated":"جلسه","updated_at":"2026-06-16T14:18:48.730Z"}
{"cache_key":"54c8ecb7d58cc6d0d7ffac0766b04a2def135e2b9c3e86bf6bb3fcc50df0b1f6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterConfiguredDefaultHelp","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"Cards explicitly assigned to the configured default agent.","text_hash":"9bb80530da1dfd473936d94642b83cc668b7362cb65675a565f17569937af92f","tgt_lang":"fa","translated":"کارت‌هایی که صراحتاً به عامل پیش‌فرض پیکربندی‌شده تخصیص یافته‌اند.","updated_at":"2026-06-17T14:17:45.319Z"}
{"cache_key":"559e2b5c160f48b34db11fc54d811601888ed2667203f5ee61798fdcb822d07a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/fa.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"fa","translated":"همه کارت‌ها","updated_at":"2026-06-17T14:17:45.319Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:03.446Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:07:07.541Z",
"locale": "fr",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -16,6 +16,7 @@
{"cache_key":"29543289b89e37bfcf5463f542622dbc78302bfc7b168de33bc9c12a4a75a80b","model":"gpt-5.5","provider":"openai","segment_id":"subtitles.skillWorkshop","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Review, refine, and apply proposals before they become live skills.","text_hash":"f907c72e9f18a205027257cd6fecdd52b03732227a17dcec0db038e11de3f8cc","tgt_lang":"fr","translated":"Examinez, affinez et appliquez les propositions avant quelles ne deviennent des skills actives.","updated_at":"2026-05-31T21:48:25.015Z"}
{"cache_key":"32508a245b7f297458454d0d6b421f18bfb8e5b30cc485b6c8efb83efe906823","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"fr","translated":"60s","updated_at":"2026-06-17T14:14:37.466Z"}
{"cache_key":"33f08936ac956a25ff1afbce1f6967a17269c18d5c829f5fc007dde85c338703","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"fr","translated":"Toutes les cartes","updated_at":"2026-06-17T14:14:32.183Z"}
{"cache_key":"35481b71eba2317119d4dbf7dbed0f878b2be2d53e7b666729eeed5f8c10d827","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"fr","translated":"Commentaire","updated_at":"2026-07-01T01:07:07.533Z"}
{"cache_key":"3bc46432de64f9d82a3e78b6cbc8170448add64ef00783823c1f0266d24ad3b8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterConfiguredDefaultHelp","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Cards explicitly assigned to the configured default agent.","text_hash":"9bb80530da1dfd473936d94642b83cc668b7362cb65675a565f17569937af92f","tgt_lang":"fr","translated":"Cartes explicitement assignées à l'agent par défaut configuré.","updated_at":"2026-06-17T14:14:32.183Z"}
{"cache_key":"3c0184dd360912c8947b9a78652bf0b39064c358953f3403ff700329ef79a36f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"fr","translated":"État du tableau de travail","updated_at":"2026-06-17T14:14:37.466Z"}
{"cache_key":"3ced3592ea6acebeac1c5b2e06684c1aabd1fdc1deed38906e62320a9fe51bd4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.agentFilterConfiguredDefault","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"{agent} (default)","text_hash":"7e996234f0fa55605720f9dc954a58411795bd882e948c87c739d43bd02137c3","tgt_lang":"fr","translated":"{agent} (par défaut)","updated_at":"2026-06-17T14:14:32.183Z"}
@@ -88,6 +89,7 @@
{"cache_key":"c85905c4a1943074a8ab1f9268d4ab9d93fd4f812cdf2c024b881f003d746cfa","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh5s","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"5s","text_hash":"93e3d8c5b10657d2884f177488b689aadf82a83f962237cb602b3314386ab3b7","tgt_lang":"fr","translated":"5s","updated_at":"2026-06-17T14:14:37.466Z"}
{"cache_key":"c887bbafc896df50744c8745177bdd449c1e66a8962c65af5b22e8f3221ac9d2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.read","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Read","text_hash":"9b9a8d05a7ec353bda84f9c1bb3178c299de3001b5e970508ddc889c487f92ca","tgt_lang":"fr","translated":"Lu","updated_at":"2026-06-16T14:14:38.496Z"}
{"cache_key":"ca4f3560f1c3259a3c8c58d0151768624f085c2bfbf4d48f3c30d2fe47fb794c","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.lastRefreshed","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Updated {time}","text_hash":"2f87419441e6111b4d62893d3c4ef5ddeb2c8e1af82fabab6132856faf77f907","tgt_lang":"fr","translated":"Mis à jour {time}","updated_at":"2026-06-17T14:14:37.466Z"}
{"cache_key":"cb49264e87f513faec8b9301db790ce23fef424469204a743337e52a199f291e","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"fr","translated":"Conserver le commentaire après la réponse finale","updated_at":"2026-07-01T01:07:07.533Z"}
{"cache_key":"ceea53168997ca7b65bf9974ae044a3bfb38b661e2cb54a1ef1a5e9e7f99b9ec","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewRunning","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Running","text_hash":"f4ccae29e1bb0c20a124570a1b43f4347ea94bba9f84ffdfddd9c7445b126128","tgt_lang":"fr","translated":"En cours","updated_at":"2026-06-17T14:14:32.183Z"}
{"cache_key":"cf26b76ef0c037d17852ff2a01ea528d6cf86c0b5c6856c2b91db0abaac57de6","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailUpdatedValue","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Updated: {time}","text_hash":"5e72d5445f018c9d08aa34ae0178fb9aa49eea6a0afd0c8d379f20b7af3e8aa0","tgt_lang":"fr","translated":"Mis à jour : {time}","updated_at":"2026-06-16T14:14:26.985Z"}
{"cache_key":"d0350ed07e280443fb772ba1be389039f10902a99239608766e4ab55b84e69ad","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.files","source_path":"ui/src/i18n/locales/fr.ts","src_lang":"en","text":"Workspace","text_hash":"87bb59ba2f92f2a5a9f13e021fd58dd14ae5c065b1046146875e6e68d5ebc8b7","tgt_lang":"fr","translated":"Espace de travail","updated_at":"2026-06-16T14:14:33.243Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:04.111Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:07:58.632Z",
"locale": "id",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -11,6 +11,7 @@
{"cache_key":"16ac4c899924f98a6a2920c90997debeac224231488a7e8d10888054b988216d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailWorkerProtocol","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Worker protocol","text_hash":"e445d823cfa48c4e8fa1d8854771e9939955e772428be6d7957deec0f7968764","tgt_lang":"id","translated":"Protokol worker","updated_at":"2026-06-16T14:16:44.079Z"}
{"cache_key":"1ca16289a1c9ee2d9bc5ede8a75396e8d9356dfdc6d0984fb331101587d4f422","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.lastRefreshed","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Updated {time}","text_hash":"2f87419441e6111b4d62893d3c4ef5ddeb2c8e1af82fabab6132856faf77f907","tgt_lang":"id","translated":"Diperbarui {time}","updated_at":"2026-06-17T14:16:03.086Z"}
{"cache_key":"1e575f21e4b5bad1a0669038c53673ae26721b967599fd634483562dc92a5637","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewAll","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"All cards","text_hash":"2306120917506b5998ec702f3661068b102dac538ba4c9e4634d65fe33eea98a","tgt_lang":"id","translated":"Semua kartu","updated_at":"2026-06-17T14:15:58.283Z"}
{"cache_key":"2214a5eb361fa9f77286505dddbb064637bcc50091600ea9f47a0c146b15e737","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"id","translated":"Komentar","updated_at":"2026-07-01T01:07:58.622Z"}
{"cache_key":"227a95d5e4f762423adc7b47def93a1c5f8d0e45ee05c32c7ab429a3ba398a93","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailDiagnostics","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Diagnostics","text_hash":"268f14bbfe119c1e92150583af960a086d7db9619a097f8aa72ff6779842f610","tgt_lang":"id","translated":"Diagnostik","updated_at":"2026-06-16T14:16:44.079Z"}
{"cache_key":"23db780eaf067ba537b4fec8e0ee4d135d25682e28dd61fac94aa127848524b8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyStatusMissing","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Missing","text_hash":"6be36ca49ee85210c5d1ad9c377d90a9859c66d889110a2a5b0dccd390d12e20","tgt_lang":"id","translated":"Hilang","updated_at":"2026-06-16T14:16:50.406Z"}
{"cache_key":"243f9918d0f1e3d9ee9c3c0e560901be19c81e478b17cb0f7ea98b6bf9a9fc34","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailOperatorNotes","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Operator notes","text_hash":"7d2a121620cebfb9c4f6c0f82b693b75d65a4210b8232d77ef87e45fce334347","tgt_lang":"id","translated":"Catatan operator","updated_at":"2026-06-16T14:16:44.079Z"}
@@ -103,6 +104,7 @@
{"cache_key":"da6a4973169844d42ff83c5ef81720c02c4e49e1f8af9d4739ffabb2792af076","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.emptyFilteredHint","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Change the view, search, priority, agent, or archive filter.","text_hash":"049dfae940263ace9707334af06b298c1223c38a449b1cec5a712553badebbd0","tgt_lang":"id","translated":"Ubah filter tampilan, pencarian, prioritas, agen, atau arsip.","updated_at":"2026-06-17T14:16:03.086Z"}
{"cache_key":"db0ab96e8ec3965f1ac322a9152776dd03849c5c65facbc7d3c4db16698b4a03","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.changed","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Changed","text_hash":"2a6141e43be0c2125e3b5d9f74b4ff1261a0b320ff927c83d4d9b1b65585bad7","tgt_lang":"id","translated":"Diubah","updated_at":"2026-06-16T14:16:57.267Z"}
{"cache_key":"dbd97a91bf0cda6d66e22e140333d00e402ba31801432e7f1287141a5be8b3ae","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.session","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Session","text_hash":"6959b4159575d8dd76d9f3bbe2c6437904f861e7860c35abd18deffb1c3425a0","tgt_lang":"id","translated":"Sesi","updated_at":"2026-06-16T14:16:57.267Z"}
{"cache_key":"df66b8402857730723182ecd1fbb8c77ca79e4c7539e4269eb19ca3f7f4258f1","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"id","translated":"Simpan komentar setelah jawaban akhir","updated_at":"2026-07-01T01:07:58.622Z"}
{"cache_key":"df7bab00f1962121b0163be590b8067de30660a13ac55ca455b2678170f234d0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.parentFolder","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Parent folder","text_hash":"158f5a01ef8cfb1e6d91f8c321dd3a63f5e457f9650eecd662857701762bd31d","tgt_lang":"id","translated":"Folder induk","updated_at":"2026-06-16T14:16:57.267Z"}
{"cache_key":"e0737763eb51a1941b8cccb78d07d18432f7685c3e7f35b454f4ad894fa2476f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependenciesBlocked","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"{count} blocked","text_hash":"fb39869b0fb3b8933126014e5c3739d7d67a620b8369781ca27e7395c595bde8","tgt_lang":"id","translated":"{count} terblokir","updated_at":"2026-06-16T14:16:50.406Z"}
{"cache_key":"e2c8af96e934057f2b7fc227e2eced830ee92d6aab1f84a51b93667e24097977","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.refreshError","source_path":"ui/src/i18n/locales/id.ts","src_lang":"en","text":"Refresh failed","text_hash":"8fa7e6d90bef4e5cb735233347bf6a71b5b30d96e7c1a50b73f10cb441b275c2","tgt_lang":"id","translated":"Penyegaran gagal","updated_at":"2026-06-17T14:16:03.086Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:03.766Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:07:32.299Z",
"locale": "it",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -49,6 +49,7 @@
{"cache_key":"522aad631e9eb6e8b781667f04babfa67e7e872f356e3bf006fffcf0047219a0","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailNotePlaceholder","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Add a decision, blocker, or proof note...","text_hash":"0e40ea8371be2fcbd8379458b0da541ca0dce5dc86357dea64a4d8fac1c742dc","tgt_lang":"it","translated":"Aggiungi una decisione, un blocco o una nota di verifica...","updated_at":"2026-06-16T14:15:46.111Z"}
{"cache_key":"527f7b455533c166625ce1dbeabc38f4490c46b0cf1bb0b128695c75c682c024","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.noSearchResults","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"No matching files.","text_hash":"6ba2ede6c6019b640f63e7e48c5ee8238e701c6e539ce9abb5a7a9d9c71d8a73","tgt_lang":"it","translated":"Nessun file corrispondente.","updated_at":"2026-06-16T14:15:52.375Z"}
{"cache_key":"5a9548f1084f3cf999e6ebe5a9f4936eef0a834882640344c5ae0ae724cdf8b3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewReview","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Review","text_hash":"aff0766a5290e117b8433c351bae7b7b23bed682b2369bd822d88a647cc58512","tgt_lang":"it","translated":"Revisione","updated_at":"2026-06-17T14:19:01.168Z"}
{"cache_key":"5fd9092703d43b8ce7f885f30ed053516a60425f8f1384dd1c2ed5373e844ab8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"it","translated":"Mantieni il commento dopo la risposta finale","updated_at":"2026-07-01T01:07:32.292Z"}
{"cache_key":"5fddf1e0355e6c7ad4b84090846ae14761144c19b8ee135c3dd639e9e0652e8d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.actions","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Workspace file actions","text_hash":"461817d921bc7672e95fe4a3b23f4ac2a4a20e35b3d6eef3f02e8f5ba4201050","tgt_lang":"it","translated":"Azioni sui file dell'area di lavoro","updated_at":"2026-06-16T14:15:52.376Z"}
{"cache_key":"63c50429a6e4dfd077cc94e3719c1f6a81da1831499bbbdeb4dd873408136fb3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.label","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Session workspace","text_hash":"c0e8ea0cf983d14e8ba3f8fc28976954d637fd50f807c69e9695715fd2384b78","tgt_lang":"it","translated":"Workspace sessione","updated_at":"2026-06-16T14:15:46.111Z"}
{"cache_key":"64d7150aad7e267d8fd248a1c6be0d8e9c45f75f65e3b7942da5a4cb1992b587","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.collapse","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Collapse session workspace","text_hash":"b6174b882c37a98e640339d728652a0c1fa70d28ed53d8ccfb6e99363e86973b","tgt_lang":"it","translated":"Comprimi workspace sessione","updated_at":"2026-06-16T14:15:46.112Z"}
@@ -70,6 +71,7 @@
{"cache_key":"81435fccbed10b32567dd2f9d1a96774ee201ea88d8244a9ac65c535575ef840","model":"gpt-5.5","provider":"openai","segment_id":"languages.hi","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"हिन्दी (Hindi)","text_hash":"fab2abfce45382f3031c59477017700a8cb5dfaf8d15379dc24304809b97c7d5","tgt_lang":"it","translated":"हिन्दी (Hindi)","updated_at":"2026-06-26T21:43:33.749Z"}
{"cache_key":"85132489f056e73c204760a92a7fcebba0bc13187acb73da9c1bd32db8c1a9d5","model":"gpt-5.5","provider":"openai","segment_id":"subtitles.skillWorkshop","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Review, refine, and apply proposals before they become live skills.","text_hash":"f907c72e9f18a205027257cd6fecdd52b03732227a17dcec0db038e11de3f8cc","tgt_lang":"it","translated":"Esamina, perfeziona e applica le proposte prima che diventino skill attive.","updated_at":"2026-05-31T21:48:28.496Z"}
{"cache_key":"8ae364941577e40a34bbbb97bebe2005a623b0a7c2cdfd93a46439b5c1c42074","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"it","translated":"Stato workboard","updated_at":"2026-06-17T14:19:20.279Z"}
{"cache_key":"8bc992c95973ae45bbe50bba495103ec642179b547f3643035e12b23f4e19702","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"it","translated":"Commento","updated_at":"2026-07-01T01:07:32.292Z"}
{"cache_key":"8dcdaf0c7f0c5f02a4ecbf2271b20bc87e2d68c1bf337ef96731fd19ac95ce83","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.badgeHeartbeat","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"heartbeat {age}","text_hash":"000637b3800ae069edbbe207cfad0a3f5037f06e9661ee89d70a1dfe6f404485","tgt_lang":"it","translated":"heartbeat {age}","updated_at":"2026-06-17T14:19:20.279Z"}
{"cache_key":"8ea019ae4e670fe0a09e70a5fd660a9425bcfa3755f554e9a790af433ca13c97","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh60s","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"60s","text_hash":"f79f071ab5b033ca8fb42c077f39708930d194b18f4608eb26ac1d9665a8836f","tgt_lang":"it","translated":"60s","updated_at":"2026-06-17T14:19:20.279Z"}
{"cache_key":"919f2fc1e1cb9336975df8d8ff3d5a4d38ab70fea92c19ba3e6092513395f974","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatAria","source_path":"ui/src/i18n/locales/it.ts","src_lang":"en","text":"Use current chat for revision requests","text_hash":"9c551a423ae74aedaaa90e4df9899dbdc02f846d6ee058bf2576a812e2c52119","tgt_lang":"it","translated":"Usa la chat corrente per le richieste di revisione","updated_at":"2026-06-16T14:15:39.914Z"}
+3 -6
View File
@@ -1,14 +1,11 @@
{
"fallbackKeys": [
"chat.commentaryLabel",
"chat.commentaryToggle"
],
"generatedAt": "2026-06-30T23:31:03.238Z",
"fallbackKeys": [],
"generatedAt": "2026-07-01T01:06:53.052Z",
"locale": "ja-JP",
"model": "claude-opus-4-8",
"provider": "anthropic",
"sourceHash": "2efa88c26ff88470a76b18ae218dd9c325165f98587f359ed51dbfb51a7d8428",
"totalKeys": 1418,
"translatedKeys": 1416,
"translatedKeys": 1418,
"workflow": 1
}
+2
View File
@@ -50,6 +50,7 @@
{"cache_key":"680d8cfcc46f5bf79ff5ea1b57b5f7fed47326f0aff56b5e3709fa088fd1bb2c","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.viewReady","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Ready","text_hash":"5fa7aac5375c5815787fba3f49559f9b45b14023147ce0652803387974144e5f","tgt_lang":"ja-JP","translated":"準備完了","updated_at":"2026-06-17T14:14:04.173Z"}
{"cache_key":"6e97dbe68af401d3c59bd5ab97cf295a1aa5a0584b20f19bf841eb9be3d54866","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.artifactCount","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"{count} artifacts","text_hash":"022b6b55a10f1864b7aff7c307e49ab37e11e6999111fb87349f040d100abba3","tgt_lang":"ja-JP","translated":"{count} 件の成果物","updated_at":"2026-06-16T14:14:19.257Z"}
{"cache_key":"6fd643605be97b117d68111ea4ed6d6da05c0ce7f2880df9b15e63d79be3411d","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.workspaceFiles.noBrowserFiles","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"No files in this folder.","text_hash":"3847020c79b1c74e28aa550f0ae53838b764e87f1daf1480dd6aae45ae0529d6","tgt_lang":"ja-JP","translated":"このフォルダーにファイルはありません。","updated_at":"2026-06-16T14:14:19.257Z"}
{"cache_key":"6fd82965e1b9919018ec76c9379372b26fc4e633cab0f1c72647895a671fdf45","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryLabel","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Commentary","text_hash":"4a7a75ab79cde05b0b1baa8f7a704c991586ea44d7e7793e57178ef56f778ff8","tgt_lang":"ja-JP","translated":"コメント","updated_at":"2026-07-01T01:06:53.045Z"}
{"cache_key":"7032ea193e157bd2e91c64f3fc49a6f9711b68e0495bb57c71b650f95bbcc194","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthLabel","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Workboard health","text_hash":"85416c4a6d64e35611bdd9747b82815936c38b41d820796ba1fbfbb7539d906b","tgt_lang":"ja-JP","translated":"ワークボードの状態","updated_at":"2026-06-17T14:14:09.255Z"}
{"cache_key":"72c184ed5db446880bf6d56893c5f172b86ebd075fac9a236af5af6e86d2e941","model":"gpt-5.5","provider":"openai","segment_id":"languages.ru","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Русский (Russian)","text_hash":"ea81bf0fd46410b501bddee074ab6f00b0cdf377a6cafe608dcf2c28f7cb2f4e","tgt_lang":"ja-JP","translated":"Русский (ロシア語)","updated_at":"2026-06-26T21:43:27.880Z"}
{"cache_key":"7a805e49dd5b20f9b92d98c5001a31aa3eb357c6eaee8a9819e881abb4999e9a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.autoRefresh5s","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"5s","text_hash":"93e3d8c5b10657d2884f177488b689aadf82a83f962237cb602b3314386ab3b7","tgt_lang":"ja-JP","translated":"5秒","updated_at":"2026-06-17T14:14:09.255Z"}
@@ -66,6 +67,7 @@
{"cache_key":"8ccfbd67b038beb504411a4aad18dabf77d1c9aef64db7f85528256739135dc2","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationSkills","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Skills: {skills}","text_hash":"4788d5f9db66e1421a762bbd942c64450c73d2145a6ef929ce32a919a0f2e3a1","tgt_lang":"ja-JP","translated":"Skills: {skills}","updated_at":"2026-06-16T14:14:04.101Z"}
{"cache_key":"8dfc5efe84dea73600d09ebc9284881470867cc6bf5a9ab9846ee4614af3f69f","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailAutomationWorkspace","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Workspace: {workspace}","text_hash":"17f5e696e557a646a9003fc8448f6f6761f5fe6bdf7478f750f471496e87c17b","tgt_lang":"ja-JP","translated":"ワークスペース: {workspace}","updated_at":"2026-06-16T14:14:04.101Z"}
{"cache_key":"8f13222e40ef70c72d03d866d5802e71b1b9901160007d84669961432e0d2681","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.dependencyMissing","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"{parent} (missing)","text_hash":"8daa419059727391c01e3b7021e05d8d70b4da67f9c57cd2d80f302af77aac53","tgt_lang":"ja-JP","translated":"{parent}(欠落)","updated_at":"2026-06-16T14:14:13.256Z"}
{"cache_key":"8fb588b6b5e3c2da65abd9016c94517733e86b4a3286dfed1b05bc71a89637f3","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.commentaryToggle","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Keep commentary after the final answer","text_hash":"febdff0791d1ceeddb2b7596da8282f4dcfd5cff4cad11dea4c0a23944cd8ffe","tgt_lang":"ja-JP","translated":"最終回答の後にコメントを残す","updated_at":"2026-07-01T01:06:53.045Z"}
{"cache_key":"9182bd9f8c396e9816aeac9e137bd8bae0d451e4ec8fb9139032491c4e5d5354","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.healthStale","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"stale","text_hash":"a03f2386ae06b21109577020844df367857b72c2fcce384c1896fed98a89c82b","tgt_lang":"ja-JP","translated":"古い","updated_at":"2026-06-17T14:14:09.255Z"}
{"cache_key":"93309fb097ac6115ffa01bb8a34c155ed43cae5a9c78fc137d45bb1d1bd13c0a","model":"claude-opus-4-8","provider":"anthropic","segment_id":"skillWorkshop.header.useCurrentChatTooltip","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Send revision requests to the current chat session instead of the proposal's workshop session.","text_hash":"9db782d40e88750d4faed33c8a73c24552070f101483881c60af8cf446c674a6","tgt_lang":"ja-JP","translated":"修正リクエストを提案のワークショップセッションではなく現在のチャットセッションに送信します。","updated_at":"2026-06-16T14:14:04.101Z"}
{"cache_key":"93aa12fedaab987305f07af92bc0269591826006609125f17a2dc90afd7e6db8","model":"claude-opus-4-8","provider":"anthropic","segment_id":"workboard.detailNotePlaceholder","source_path":"ui/src/i18n/locales/ja-JP.ts","src_lang":"en","text":"Add a decision, blocker, or proof note...","text_hash":"0e40ea8371be2fcbd8379458b0da541ca0dce5dc86357dea64a4d8fac1c742dc","tgt_lang":"ja-JP","translated":"決定事項、ブロッカー、証跡メモを追加...","updated_at":"2026-06-16T14:14:13.256Z"}

Some files were not shown because too many files have changed in this diff Show More