mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
fix(channels): show progress-card notes in drafts (#144790)
Closes #143431. ## What Problem This Solves Markdown-only progress cards showed “Progress updated” in channel drafts, hiding the note. ## Why This Change Was Made The shared Markdown parser removes formatting and authored HTML while preserving code literals and link metadata. This refines #139206 while retaining its HTML boundary. The card store remains the sole writer; transactions order updates and reset clears. ## User Impact Notes are readable within existing headline limits. Checklist counts, replacement, clearing, and stored Markdown keep their behavior. ## Evidence The registered-tool-to-final-renderer regression covers four literal inputs across three output variants. It fails in ten cases on the previous head and passes all twelve after the correction. Real channel recordings confirm literal characters, inactive links, replacement, clearing, and final delivery. A Slack dispatch regression confirms that a fresh “Checking results” preamble and the matching projected note produce one native title. It fails with the duplicate title before the fix and passes after. The corrected branch passed 263 tests; the corrected merged content passed 266, including all twelve literal cases. ## Compatibility No stored format, configuration key, protocol version, or SDK entrypoint changed. Existing callback and snapshot types gained optional presentation metadata. ## Consumers Both agent execution paths share the note projection. The final channel renderers must treat the prepared note as literal text while preserving authored formatting in narration and commentary. Speech, session preambles, and other plain-text callers retain their existing defaults. Web-fetch extraction and visibility use the same unchanged scanner at its new shared location. Existing code-region readers retain their prior contract. ## Invalidation Each update replaces the draft plan and note; empty input clears them. ## Tests The prepared-text contract is forwarded through the existing event and draft boundaries. Authored narration keeps its previous formatting. The broader merged selection passed 782 tests; later formatter changes passed the recorded focused selections, including 120 tests on the final merged content. These are overlapping selections, not a summed test total. Live recordings precede equivalent encoder centralization; final focused tests cover that change. Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
parent
f765199d9c
commit
5541c3d396
41 changed files with 750 additions and 152 deletions
|
|
@ -89,7 +89,7 @@ A full in-place conversation reset (`/reset` without `soft`, or `sessions.reset`
|
|||
|
||||
## Where the card appears
|
||||
|
||||
Channels with progress drafts show the latest checklist in active `partial`, `block`, and `progress` previews, subject to their preview settings and line limits. Card updates supply a completion count, or `Progress updated` for a note without steps; they do not copy the note's Markdown or HTML into tool summaries. Telegram uses native checkboxes with `channels.telegram.richMessages: true` and readable HTML checklists otherwise. See [Streaming and chunking](/concepts/streaming#progress-draft-rendering).
|
||||
Channels with progress drafts show the latest checklist in active `partial`, `block`, and `progress` previews, subject to their preview settings and line limits. Cards with steps supply a completion count. Notes without steps supply readable text with Markdown formatting and authored HTML removed, subject to the existing headline limit. A note without readable text supplies `Progress updated`. The full Markdown remains in the durable card. Telegram uses native checkboxes with `channels.telegram.richMessages: true` and readable HTML checklists otherwise. See [Streaming and chunking](/concepts/streaming#progress-draft-rendering).
|
||||
|
||||
The current chat keeps exactly one live card, in the collapsible surface inside the composer, at every width. Opening a side panel does not move it out of the conversation. The dashboard widget and the session hovercard are separate read-only placements: hover a session row in the sidebar or a session-reference link in chat to see the same card for that session. All card placements read the same Gateway-backed state and refresh after `progressCard.changed` notifications. A notification is a refresh hint, including a null revision; clients confirm a removal with a read or clear response for that session and agent.
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export class CodexReasoningProjection {
|
|||
this.emitPlanUpdate(
|
||||
{
|
||||
explanation,
|
||||
...(params.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
|
||||
steps: plan,
|
||||
},
|
||||
source,
|
||||
|
|
@ -153,7 +154,7 @@ export class CodexReasoningProjection {
|
|||
}
|
||||
|
||||
private emitPlanUpdate(
|
||||
params: { explanation?: string | null; steps?: AgentPlanStep[] },
|
||||
params: { explanation?: string | null; explanationFormat?: "plain"; steps?: AgentPlanStep[] },
|
||||
source: PlanUpdateSource = "codex-app-server",
|
||||
): void {
|
||||
if (!params.explanation && params.steps === undefined) {
|
||||
|
|
@ -166,6 +167,7 @@ export class CodexReasoningProjection {
|
|||
title: "Plan updated",
|
||||
source,
|
||||
...(params.explanation ? { explanation: params.explanation } : {}),
|
||||
...(params.explanationFormat ? { explanationFormat: params.explanationFormat } : {}),
|
||||
...(params.steps ? { steps: params.steps } : {}),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ export class CodexAppServerEventProjector extends CodexTurnProjection {
|
|||
const projected: JsonObject = {
|
||||
plan: update.steps,
|
||||
...(update.explanation ? { explanation: update.explanation } : {}),
|
||||
...(update.explanationFormat ? { explanationFormat: update.explanationFormat } : {}),
|
||||
};
|
||||
await this.reasoningProjection.handleTurnPlanUpdated(projected, "openclaw");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2943,6 +2943,34 @@ describe("runCodexAppServerAttempt", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const noteResponse = await harness.handleServerRequest({
|
||||
id: "request-plan-note",
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-plan-note",
|
||||
namespace: null,
|
||||
tool: "progress_card",
|
||||
arguments: {
|
||||
markdown:
|
||||
'<progress aria-label="private" value="1" max="2"></progress>\n\n**Working** [results](https://example.com "<script>").',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(noteResponse).toMatchObject({ success: true });
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "openclaw",
|
||||
explanation: "Working results.",
|
||||
explanationFormat: "plain",
|
||||
steps: [],
|
||||
},
|
||||
});
|
||||
|
||||
const clearResponse = await harness.handleServerRequest({
|
||||
id: "request-plan-clear",
|
||||
method: "item/tool/call",
|
||||
|
|
@ -2956,7 +2984,7 @@ describe("runCodexAppServerAttempt", () => {
|
|||
},
|
||||
});
|
||||
expect(clearResponse).toMatchObject({ success: true });
|
||||
expect(executeProgressCard).toHaveBeenCalledTimes(3);
|
||||
expect(executeProgressCard).toHaveBeenCalledTimes(4);
|
||||
expect(onAgentEvent).toHaveBeenCalledWith({
|
||||
stream: "plan",
|
||||
data: {
|
||||
|
|
@ -3200,47 +3228,6 @@ describe("runCodexAppServerAttempt", () => {
|
|||
expect(closeAndWait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("projects dynamic progress cards through the shared safe status contract", async () => {
|
||||
const params = createRunParams();
|
||||
const onAgentEvent = vi.fn();
|
||||
params.onAgentEvent = onAgentEvent;
|
||||
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
|
||||
|
||||
await projector.recordDynamicProgressCardUpdate({
|
||||
markdown: '<progress aria-label="private" value="1" max="2"></progress>',
|
||||
plan: [{ step: "Ship", status: "completed" }],
|
||||
});
|
||||
await projector.recordDynamicProgressCardUpdate({ markdown: "Working" });
|
||||
await projector.recordDynamicProgressCardUpdate({});
|
||||
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "openclaw",
|
||||
explanation: "1/1 complete",
|
||||
steps: [{ step: "Ship", status: "completed" }],
|
||||
},
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "openclaw",
|
||||
explanation: "Progress updated",
|
||||
steps: [],
|
||||
},
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(3, {
|
||||
stream: "plan",
|
||||
data: { phase: "update", title: "Plan updated", source: "openclaw", steps: [] },
|
||||
});
|
||||
expect(JSON.stringify(onAgentEvent.mock.calls)).not.toContain("<progress");
|
||||
expect(JSON.stringify(onAgentEvent.mock.calls)).not.toContain("private");
|
||||
});
|
||||
|
||||
it("keeps searchable Codex dynamic tools canonical in mirrored transcript snapshots", async () => {
|
||||
const params = createRunParams();
|
||||
const projector = new CodexAppServerEventProjector(params, "thread-1", "turn-1");
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export function createDiscordMessageProgressRuntime(params: {
|
|||
if (payload.phase === "update") {
|
||||
return await draftPreview.pushPlanProgress(payload.steps, {
|
||||
explanation: payload.explanation,
|
||||
explanationFormat: payload.explanationFormat,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// Discord test API exposes transcript-provider fixtures without deep extension imports.
|
||||
export { createDiscordDraftPreviewController } from "./src/monitor/message-handler.draft-preview.js";
|
||||
export { RequestClient } from "./src/internal/discord.js";
|
||||
export {
|
||||
discordVoiceTranscriptsSourceProvider,
|
||||
setDiscordTranscriptsVoiceManager,
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export async function createMatrixDraftController(params: {
|
|||
}
|
||||
return await progressDraft.pushPlanProgress(payload.steps, {
|
||||
explanation: payload.explanation,
|
||||
explanationFormat: payload.explanationFormat,
|
||||
});
|
||||
},
|
||||
onApprovalEvent: async (payload) => {
|
||||
|
|
|
|||
|
|
@ -522,6 +522,7 @@ export async function dispatchMattermostInboundTurn(
|
|||
const boundarySettled = enterBlockPreviewActivity("tool");
|
||||
const progressSettled = progressDraft.pushPlanProgress(payloadValue.steps, {
|
||||
explanation: payloadValue.explanation,
|
||||
explanationFormat: payloadValue.explanationFormat,
|
||||
});
|
||||
previewBoundaryController.noteUpdate();
|
||||
const [, visible] = await Promise.all([boundarySettled, progressSettled]);
|
||||
|
|
|
|||
|
|
@ -640,6 +640,7 @@ export function createMSTeamsReplyDispatcher(params: {
|
|||
}
|
||||
await streamController.pushPlanProgress(normalizeAgentPlanSteps(payload.steps), {
|
||||
explanation: typeof payload.explanation === "string" ? payload.explanation : undefined,
|
||||
explanationFormat: payload.explanationFormat === "plain" ? "plain" : undefined,
|
||||
});
|
||||
return false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -233,6 +233,8 @@ export function createTeamsReplyStreamController(params: {
|
|||
|
||||
// Teams cannot delete an empty interim card; final delivery settles it.
|
||||
const progressDraft = createChannelProgressDraftCompositor({
|
||||
// Informative Teams activities are already plain text, unlike Markdown draft transports.
|
||||
formatPlainText: (text) => text,
|
||||
entry: params.msteamsConfig,
|
||||
mode: streamMode,
|
||||
active: Boolean(stream) && streamMode === "progress",
|
||||
|
|
@ -368,7 +370,7 @@ export function createTeamsReplyStreamController(params: {
|
|||
|
||||
async pushPlanProgress(
|
||||
steps?: AgentPlanStep[],
|
||||
options?: { explanation?: string },
|
||||
options?: { explanation?: string; explanationFormat?: "plain" },
|
||||
): Promise<void> {
|
||||
await progressDraft.pushPlanProgress(steps, options);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export function createSlackDraftProgressCardRuntime(params: {
|
|||
seed: params.progressSeed,
|
||||
formatLine: formatSlackProgressDraftLine,
|
||||
narration: snapshot.statusHeadline,
|
||||
narrationFormat: snapshot.statusHeadlineFormat,
|
||||
plan: snapshot.plan,
|
||||
diffStat: snapshot.diffStat,
|
||||
});
|
||||
|
|
@ -78,16 +79,30 @@ export function createSlackDraftProgressCardRuntime(params: {
|
|||
state: DraftProgressCardState,
|
||||
) => {
|
||||
const title = params.explicitTitle ?? snapshot.statusHeadline ?? "Working";
|
||||
const titleFormat = params.explicitTitle ? undefined : snapshot.statusHeadlineFormat;
|
||||
const narration = params.explicitTitle
|
||||
? combineProgressHeadlineAndExplanation(snapshot.statusHeadline, snapshot.planExplanation)
|
||||
: snapshot.planExplanation && snapshot.planExplanation !== title
|
||||
? snapshot.planExplanation
|
||||
? snapshot.statusHeadlineFormat === "plain" || snapshot.planExplanationFormat === "plain"
|
||||
? [
|
||||
...(snapshot.statusHeadline &&
|
||||
(snapshot.statusHeadline !== snapshot.planExplanation ||
|
||||
snapshot.statusHeadlineFormat !== snapshot.planExplanationFormat)
|
||||
? [{ text: snapshot.statusHeadline, format: snapshot.statusHeadlineFormat }]
|
||||
: []),
|
||||
...(snapshot.planExplanation
|
||||
? [{ text: snapshot.planExplanation, format: snapshot.planExplanationFormat }]
|
||||
: []),
|
||||
]
|
||||
: combineProgressHeadlineAndExplanation(snapshot.statusHeadline, snapshot.planExplanation)
|
||||
: snapshot.planExplanation &&
|
||||
(snapshot.planExplanation !== title || snapshot.planExplanationFormat !== titleFormat)
|
||||
? [{ text: snapshot.planExplanation, format: snapshot.planExplanationFormat }]
|
||||
: undefined;
|
||||
const workCounter = state === "working" ? params.progressWorkCounter : undefined;
|
||||
const sessionUrl = state === "working" ? undefined : resolveSessionUrl();
|
||||
return buildSlackProgressCardBlocks({
|
||||
state,
|
||||
title,
|
||||
titleFormat,
|
||||
narration,
|
||||
plan: snapshot.plan,
|
||||
lines: resolveStructuredProgressLines(snapshot.lines),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { formatSlackError } from "../../errors.js";
|
|||
import { SLACK_EDIT_TEXT_MAX_BYTES, SLACK_TEXT_LIMIT } from "../../limits.js";
|
||||
import {
|
||||
buildSlackProgressStreamChunks,
|
||||
buildSlackProgressTextBlocks,
|
||||
reconcileSlackNativeTaskChunks,
|
||||
EMPTY_SLACK_NATIVE_STREAM_SNAPSHOT,
|
||||
type SlackNativeStreamSnapshot,
|
||||
|
|
@ -201,7 +202,14 @@ export function createSlackProgressRuntime(runtimeParams: {
|
|||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const title = normalizeProgressText(resolveNativeProgressTitle(progressDraft.getSnapshot()));
|
||||
const snapshot = progressDraft.getSnapshot();
|
||||
const title = normalizeProgressText(
|
||||
combineProgressHeadlineAndExplanation(
|
||||
explicitProgressTitle ??
|
||||
(snapshot.statusHeadlineFormat === "plain" ? undefined : snapshot.statusHeadline),
|
||||
snapshot.planExplanationFormat === "plain" ? undefined : snapshot.planExplanation,
|
||||
),
|
||||
);
|
||||
return title.length > 0 && title.includes(candidate);
|
||||
};
|
||||
|
||||
|
|
@ -368,7 +376,9 @@ export function createSlackProgressRuntime(runtimeParams: {
|
|||
text: previewText,
|
||||
blocks: progressCard.resolvePresentation(snapshot, "working"),
|
||||
}
|
||||
: previewText,
|
||||
: snapshot.preparedBlocks
|
||||
? { text: previewText, blocks: buildSlackProgressTextBlocks(snapshot.preparedBlocks) }
|
||||
: previewText,
|
||||
);
|
||||
if (options?.flush) {
|
||||
await draftStream.flush();
|
||||
|
|
@ -477,11 +487,15 @@ export function createSlackProgressRuntime(runtimeParams: {
|
|||
delivery.streamFailed = false;
|
||||
};
|
||||
|
||||
const pushPlanProgress = async (steps?: AgentPlanStep[], explanation?: string) => {
|
||||
const pushPlanProgress = async (
|
||||
steps?: AgentPlanStep[],
|
||||
explanation?: string,
|
||||
explanationFormat?: "plain",
|
||||
) => {
|
||||
if (isProgressMode && slackProgressStyle === "compact") {
|
||||
return false;
|
||||
}
|
||||
return await progressDraft.pushPlanProgress(steps, { explanation });
|
||||
return await progressDraft.pushPlanProgress(steps, { explanation, explanationFormat });
|
||||
};
|
||||
|
||||
const updateDraftFromPartial = (text?: string) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Slack tests cover dispatch.preview fallback plugin behavior.
|
||||
import { projectProgressCardChannelUpdate } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
createTestRegistry,
|
||||
resetPluginRuntimeStateForTest,
|
||||
|
|
@ -171,6 +172,7 @@ let mockedReplyOptionEvents: Array<
|
|||
kind: "plan";
|
||||
phase?: string;
|
||||
explanation?: string;
|
||||
explanationFormat?: "plain";
|
||||
steps: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
|
||||
}
|
||||
| { kind: "concurrent_items"; progressTexts: string[] }
|
||||
|
|
@ -1079,6 +1081,7 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
|||
await params.replyOptions?.onPlanUpdate?.({
|
||||
phase: entry.phase,
|
||||
explanation: entry.explanation,
|
||||
explanationFormat: entry.explanationFormat,
|
||||
steps: entry.steps,
|
||||
});
|
||||
} else if (entry.kind === "concurrent_items") {
|
||||
|
|
@ -3526,6 +3529,29 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
|
|||
taskUpdate("plan_step_1", "Patch", "in_progress"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "deduplicates a native title shared by a fresh preamble and a prepared note",
|
||||
events: [
|
||||
{
|
||||
kind: "item",
|
||||
itemKind: "preamble",
|
||||
itemId: "preamble-1",
|
||||
progressText: "Checking results",
|
||||
},
|
||||
{
|
||||
kind: "plan",
|
||||
phase: "update",
|
||||
...projectProgressCardChannelUpdate({ markdown: "**Checking** results" }),
|
||||
steps: [],
|
||||
},
|
||||
],
|
||||
updates: [
|
||||
planUpdate("Checking results"),
|
||||
taskUpdate(expect.any(String), "Update Plan", "in_progress", {
|
||||
details: "Checking results",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "starts native Slack progress from an explanation-only plan",
|
||||
events: [
|
||||
|
|
|
|||
|
|
@ -603,7 +603,11 @@ async function dispatchSlackMessageWithSetup(
|
|||
if (payload.phase !== "update") {
|
||||
return false;
|
||||
}
|
||||
return await progress.pushPlanProgress(payload.steps, payload.explanation);
|
||||
return await progress.pushPlanProgress(
|
||||
payload.steps,
|
||||
payload.explanation,
|
||||
payload.explanationFormat,
|
||||
);
|
||||
},
|
||||
onApprovalEvent: async (payload) => {
|
||||
return await progress.progressDraft.pushApprovalEvent(payload);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,35 @@ function field(text: string) {
|
|||
return { type: "mrkdwn" as const, text: truncateSlackText(text, SLACK_PROGRESS_FIELD_MAX) };
|
||||
}
|
||||
|
||||
type SlackProgressText = { text: string; format?: "plain" };
|
||||
|
||||
function progressTextSection(value: SlackProgressText, style?: "italic"): Block | KnownBlock {
|
||||
if (value.format === "plain") {
|
||||
return {
|
||||
type: "section",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: truncateSlackText(value.text, SLACK_PROGRESS_FIELD_MAX),
|
||||
emoji: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
const rendered = renderProgressCardText(value.text, style);
|
||||
const marker = style === "italic" ? "_" : "";
|
||||
return { type: "section", text: field(`${marker}${rendered}${marker}`) };
|
||||
}
|
||||
|
||||
export function buildSlackProgressTextBlocks(
|
||||
blocks: NonNullable<ChannelProgressDraftCompositorSnapshot["preparedBlocks"]>,
|
||||
): (Block | KnownBlock)[] {
|
||||
return blocks.map((block) =>
|
||||
progressTextSection({
|
||||
text: block.text,
|
||||
format: block.format === "plain" ? "plain" : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMaxLineChars(value: number | undefined, fallback: number): number {
|
||||
return value && value > 0 ? Math.floor(value) : fallback;
|
||||
}
|
||||
|
|
@ -352,9 +381,10 @@ function buildActivityText(lines: readonly ChannelProgressDraftLine[], maxLineCh
|
|||
export function buildSlackProgressCardBlocks(params: {
|
||||
state: SlackProgressCardState;
|
||||
title: string;
|
||||
titleFormat?: "plain";
|
||||
lines: readonly ChannelProgressDraftLine[];
|
||||
plan?: readonly AgentPlanStep[];
|
||||
narration?: string;
|
||||
narration?: string | readonly SlackProgressText[];
|
||||
maxLineChars?: number;
|
||||
toolCalls?: number;
|
||||
elapsedSeconds?: number;
|
||||
|
|
@ -369,7 +399,14 @@ export function buildSlackProgressCardBlocks(params: {
|
|||
maxLines: SLACK_MAX_BLOCKS,
|
||||
maxLineChars,
|
||||
});
|
||||
const narration = params.narration?.replace(/\s+/g, " ").trim();
|
||||
const narration = (
|
||||
typeof params.narration === "string" ? [{ text: params.narration }] : (params.narration ?? [])
|
||||
)
|
||||
.map(({ text, format }: SlackProgressText) => ({
|
||||
text: text.replace(/\s+/g, " ").trim(),
|
||||
format,
|
||||
}))
|
||||
.filter((part) => part.text);
|
||||
const diffStat = formatChannelProgressDraftDiffStat(params.diffStat);
|
||||
const workingFooter = [
|
||||
...(params.toolCalls && params.toolCalls > 0 ? [`🛠️ ${params.toolCalls} tools`] : []),
|
||||
|
|
@ -387,8 +424,6 @@ export function buildSlackProgressCardBlocks(params: {
|
|||
return title === undefined ? [] : [escapeSlackMrkdwn(title)];
|
||||
});
|
||||
const sections = [
|
||||
`${icon} *${renderProgressCardText(params.title.trim() || "Working", "bold")}*`,
|
||||
narration ? `_${renderProgressCardText(narration, "italic")}_` : "",
|
||||
planLines.map((line) => renderProgressCardText(line)).join("\n"),
|
||||
buildActivityText(
|
||||
params.lines.filter((line) => line.kind !== "approval" && lineTaskStatus(line) !== "error"),
|
||||
|
|
@ -397,9 +432,14 @@ export function buildSlackProgressCardBlocks(params: {
|
|||
// Attention has its own bounded section so activity truncation cannot hide it.
|
||||
joinRecentProgressRows(attention),
|
||||
];
|
||||
const blocks: (Block | KnownBlock)[] = sections
|
||||
.filter(Boolean)
|
||||
.map((text) => ({ type: "section", text: field(text) }));
|
||||
const title = params.title.trim() || "Working";
|
||||
const blocks: (Block | KnownBlock)[] = [
|
||||
params.titleFormat === "plain"
|
||||
? progressTextSection({ text: `${icon} ${title}`, format: "plain" })
|
||||
: { type: "section", text: field(`${icon} *${renderProgressCardText(title, "bold")}*`) },
|
||||
...narration.map((part) => progressTextSection(part, "italic")),
|
||||
...sections.filter(Boolean).map((text) => ({ type: "section" as const, text: field(text) })),
|
||||
];
|
||||
if (footer) {
|
||||
blocks.push({ type: "context", elements: [field(footer)] });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ export async function handlePlanUpdate(
|
|||
return payload.phase === "update" && canPushToolProgress(turn)
|
||||
? await turn.progressCompositor.pushPlanProgress(payload.steps, {
|
||||
explanation: payload.explanation,
|
||||
explanationFormat: payload.explanationFormat,
|
||||
})
|
||||
: false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ type TelegramProgressCompositor = {
|
|||
pushCommentaryProgress: (text?: string, options?: { itemId?: string }) => Promise<boolean>;
|
||||
pushPlanProgress: (
|
||||
steps?: AgentPlanStep[],
|
||||
options?: { explanation?: string },
|
||||
options?: { explanation?: string; explanationFormat?: "plain" },
|
||||
) => Promise<boolean>;
|
||||
pushPreambleHeadline: (text?: string, options?: { itemId?: string }) => Promise<boolean>;
|
||||
pushToolEvent: (payload: CallbackPayload<"onToolStart">) => Promise<boolean>;
|
||||
|
|
|
|||
|
|
@ -31,8 +31,12 @@ function isTelegramProgressPriorityLine(line: ChannelProgressDraftCompositorLine
|
|||
// Each row has one content decision; both Telegram transports use that row.
|
||||
type ProgressText = { html: string; rich: RichText };
|
||||
|
||||
function literalProgressText(text: string, style?: "bold" | "italic"): ProgressText {
|
||||
function literalProgressText(text: string, style?: "bold" | "italic" | "code"): ProgressText {
|
||||
const escaped = escapeTelegramHtml(text);
|
||||
if (style === "code") {
|
||||
// Telegram also detects bare URLs in HTML text; code entities keep prepared notes inert.
|
||||
return { html: `<code>${escaped}</code>`, rich: { type: "code", text } };
|
||||
}
|
||||
return style === "bold"
|
||||
? { html: `<b>${escaped}</b>`, rich: boldRichText(text) }
|
||||
: style === "italic"
|
||||
|
|
@ -122,11 +126,11 @@ export function renderTelegramProgressDraftPreview(
|
|||
addParagraph(literalProgressText(compactChannelProgressDraftLine(label, maxLineChars), "bold"));
|
||||
}
|
||||
if (snapshot.statusHeadline) {
|
||||
const status = markdownProgressText(
|
||||
compactChannelProgressDraftLine(snapshot.statusHeadline, maxLineChars),
|
||||
);
|
||||
const text = compactChannelProgressDraftLine(snapshot.statusHeadline, maxLineChars);
|
||||
const plain = snapshot.statusHeadlineFormat === "plain";
|
||||
const status = plain ? literalProgressText(text, "code") : markdownProgressText(text);
|
||||
addParagraph(
|
||||
label
|
||||
label || plain
|
||||
? status
|
||||
: {
|
||||
html: `<b>${status.html}</b>`,
|
||||
|
|
|
|||
2
extensions/telegram/test-api.ts
Normal file
2
extensions/telegram/test-api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { renderTelegramProgressDraftPreview } from "./src/progress-draft-preview.js";
|
||||
export { telegramHtmlToPlainTextFallback } from "./src/format.js";
|
||||
|
|
@ -195,8 +195,9 @@ type MarkdownInlineSource = {
|
|||
offsets: number[];
|
||||
};
|
||||
|
||||
type MarkdownCodeOptions = {
|
||||
type MarkdownOwnershipOptions = {
|
||||
includeSource?: boolean;
|
||||
includeText?: boolean;
|
||||
syntax?: "commonmark" | "gfm";
|
||||
};
|
||||
|
||||
|
|
@ -262,9 +263,9 @@ function captureInlineSources(text: string, sources: Map<number, MarkdownInlineS
|
|||
};
|
||||
}
|
||||
|
||||
export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptions) {
|
||||
export function parseMarkdownOwnership(text: string, options?: MarkdownOwnershipOptions) {
|
||||
if (!text) {
|
||||
return { regions: [], codeSpans: [], retainStart: 0 };
|
||||
return { regions: [], codeSpans: [], textSpans: [], retainStart: 0 };
|
||||
}
|
||||
const sources = new Map<number, MarkdownInlineSource>();
|
||||
const tables = options?.syntax !== "commonmark";
|
||||
|
|
@ -276,11 +277,15 @@ export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptio
|
|||
],
|
||||
}) as PositionedNode;
|
||||
const regions: MarkdownCodeRegion[] = [];
|
||||
const textSpans: Array<[number, number]> = [];
|
||||
const pending: PositionedNode[] = [tree];
|
||||
while (pending.length > 0) {
|
||||
const node = expectDefined(pending.pop(), "Markdown ownership node");
|
||||
const start = node.position?.start?.offset;
|
||||
const end = node.position?.end?.offset;
|
||||
if (options?.includeText && node.type === "text" && start !== undefined && end !== undefined) {
|
||||
textSpans.push([start, end]);
|
||||
}
|
||||
if (
|
||||
(node.type === "code" || node.type === "inlineCode") &&
|
||||
start !== undefined &&
|
||||
|
|
@ -302,6 +307,7 @@ export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptio
|
|||
return {
|
||||
regions,
|
||||
codeSpans: regions.map(({ start, end }): [number, number] => [start, end]),
|
||||
textSpans,
|
||||
retainStart: tree.children?.at(-1)?.position?.start?.offset ?? text.length,
|
||||
};
|
||||
}
|
||||
|
|
@ -309,7 +315,7 @@ export function parseMarkdownOwnership(text: string, options?: MarkdownCodeOptio
|
|||
/** Returns parser-owned CommonMark/GFM code ranges with block ownership. */
|
||||
export function findMarkdownCodeRegions(
|
||||
text: string,
|
||||
options?: MarkdownCodeOptions,
|
||||
options?: MarkdownOwnershipOptions,
|
||||
): MarkdownCodeRegion[] {
|
||||
return /[`~\t]| {4}/u.test(text) ? parseMarkdownOwnership(text, options).regions : [];
|
||||
}
|
||||
|
|
|
|||
37
packages/markdown-core/src/strip-html.ts
Normal file
37
packages/markdown-core/src/strip-html.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { RAW_TEXT_TAGS, readRawTextBounds } from "./html-scanner.js";
|
||||
import { matchMarkdownHtmlTag, tokenizeHtmlTags } from "./html-tags.js";
|
||||
import { parseMarkdownOwnership } from "./reasoning-tag-parser.js";
|
||||
|
||||
/** Removes authored HTML before block parsing while preserving code and escaped literals. */
|
||||
export function stripHtmlFromMarkdown(markdown: string): string {
|
||||
const { textSpans } = parseMarkdownOwnership(markdown, { includeText: true });
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
for (let start = markdown.indexOf("<"); start !== -1; start = markdown.indexOf("<", start + 1)) {
|
||||
if (!textSpans.some(([begin, end]) => start >= begin && start < end)) {
|
||||
continue;
|
||||
}
|
||||
let escaped = false;
|
||||
for (let index = start - 1; markdown[index] === "\\"; index -= 1) {
|
||||
escaped = !escaped;
|
||||
}
|
||||
if (escaped) {
|
||||
continue;
|
||||
}
|
||||
const raw = matchMarkdownHtmlTag(markdown.slice(start));
|
||||
if (!raw) {
|
||||
continue;
|
||||
}
|
||||
const tag = tokenizeHtmlTags(raw).next().value;
|
||||
output += markdown.slice(cursor, start);
|
||||
cursor =
|
||||
tag && !tag.closing && RAW_TEXT_TAGS.has(tag.name)
|
||||
? readRawTextBounds(markdown, tag.name, start + raw.length).end
|
||||
: start + raw.length;
|
||||
if (tag && (tag.name === "br" || tag.name === "hr")) {
|
||||
output += "\n";
|
||||
}
|
||||
start = cursor - 1;
|
||||
}
|
||||
return output + markdown.slice(cursor);
|
||||
}
|
||||
|
|
@ -351,7 +351,8 @@ describe("progress_card compatibility plan events", () => {
|
|||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "openclaw",
|
||||
explanation: "Progress updated",
|
||||
explanation: "Checking safe candidates.",
|
||||
explanationFormat: "plain",
|
||||
steps: [],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@
|
|||
* Converts lightweight HTML into bounded markdown/text without pulling in a full renderer.
|
||||
*/
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { stripInvisibleUnicode } from "../../infra/unicode-visibility.js";
|
||||
import { decodeHtmlEntities } from "../../shared/html-entities.js";
|
||||
import {
|
||||
RAW_TEXT_TAGS,
|
||||
isAsciiWhitespace,
|
||||
|
|
@ -16,7 +14,9 @@ import {
|
|||
readTagToken,
|
||||
readRawTextBounds,
|
||||
skipRawTextElement,
|
||||
} from "./web-fetch-html-tag.js";
|
||||
} from "../../../packages/markdown-core/src/html-scanner.js";
|
||||
import { stripInvisibleUnicode } from "../../infra/unicode-visibility.js";
|
||||
import { decodeHtmlEntities } from "../../shared/html-entities.js";
|
||||
import { sanitizeHtml } from "./web-fetch-visibility.js";
|
||||
|
||||
/** Output mode requested by web_fetch extraction. */
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
readTagToken,
|
||||
skipHtmlComment,
|
||||
startsLikeHtmlTag,
|
||||
} from "./web-fetch-html-tag.js";
|
||||
} from "../../../packages/markdown-core/src/html-scanner.js";
|
||||
|
||||
// Compile property matchers once: this list is checked for every styled element.
|
||||
const HIDDEN_STYLE_PATTERNS = (
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ export type GetReplyOptions = {
|
|||
phase?: string;
|
||||
title?: string;
|
||||
explanation?: string;
|
||||
/** Prepared literal text; unmarked explanations retain authored Markdown. */
|
||||
explanationFormat?: "plain";
|
||||
steps?: AgentPlanStep[];
|
||||
source?: string;
|
||||
}) => Promise<ProgressCallbackResult> | ProgressCallbackResult;
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ function createPlanUpdateBridge(params: {
|
|||
phase: normalizeOptionalString(evt.data.phase),
|
||||
title: normalizeOptionalString(evt.data.title),
|
||||
explanation: normalizeOptionalString(evt.data.explanation),
|
||||
...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
|
||||
steps: normalizeAgentPlanSteps(evt.data.steps),
|
||||
source: normalizeOptionalString(evt.data.source),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export function createAgentRunEventHandler(params: {
|
|||
phase: readStringValue(evt.data.phase),
|
||||
title: readStringValue(evt.data.title),
|
||||
explanation: readStringValue(evt.data.explanation),
|
||||
...(evt.data.explanationFormat === "plain" ? { explanationFormat: "plain" as const } : {}),
|
||||
steps: normalizeAgentPlanSteps(evt.data.steps),
|
||||
source: readStringValue(evt.data.source),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -381,6 +381,9 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
|||
phase: payload.phase,
|
||||
title: payload.title,
|
||||
explanation: payload.explanation,
|
||||
...(payload.explanationFormat
|
||||
? { explanationFormat: payload.explanationFormat }
|
||||
: {}),
|
||||
steps,
|
||||
source: payload.source,
|
||||
};
|
||||
|
|
@ -408,6 +411,7 @@ export async function executeDispatch(state: PrepareDispatchExecutionReadyState)
|
|||
}
|
||||
await state.sendPlanUpdate({
|
||||
explanation: normalized.explanation,
|
||||
explanationFormat: normalized.explanationFormat,
|
||||
steps,
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
|
|||
}
|
||||
|
||||
let didSendPlanStatusNotice = false;
|
||||
const formatPlanUpdateText = (payload: { explanation?: string; steps?: AgentPlanStep[] }) => {
|
||||
const formatPlanUpdateText = (payload: {
|
||||
explanation?: string;
|
||||
explanationFormat?: "plain";
|
||||
steps?: AgentPlanStep[];
|
||||
}) => {
|
||||
const explanation = payload.explanation?.replace(/\s+/g, " ").trim();
|
||||
const steps = (payload.steps ?? [])
|
||||
.map((entry) => ({ step: entry.step.replace(/\s+/g, " ").trim(), status: entry.status }))
|
||||
|
|
@ -62,10 +66,14 @@ export async function prepareDispatchExecution(state: ChooseDispatchRouteReadySt
|
|||
maxLineChars: 120,
|
||||
}).join("\n");
|
||||
}
|
||||
return explanation || "Planning next steps.";
|
||||
// Generic notices retain their shipped receipt; prepared notes belong to literal-capable drafts.
|
||||
return payload.explanationFormat === "plain"
|
||||
? "Progress updated"
|
||||
: explanation || "Planning next steps.";
|
||||
};
|
||||
const sendPlanUpdate = async (payload: {
|
||||
explanation?: string;
|
||||
explanationFormat?: "plain";
|
||||
steps?: AgentPlanStep[];
|
||||
}): Promise<void> => {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -962,6 +962,34 @@ describe("dispatchReplyFromConfig", () => {
|
|||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
||||
});
|
||||
|
||||
it("keeps prepared notes in drafts and the generic receipt in verbose notices", async () => {
|
||||
setNoAbort();
|
||||
const cfg = {
|
||||
...emptyConfig,
|
||||
agents: { defaults: { verboseDefault: "on" } },
|
||||
} satisfies OpenClawConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({ Provider: "telegram", ChatType: "direct" }),
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyResolver: async (_ctx, opts) => {
|
||||
await opts?.onPlanUpdate?.({
|
||||
phase: "update",
|
||||
explanation: "Use **literal** and [label](https://example.com).",
|
||||
explanationFormat: "plain",
|
||||
steps: [],
|
||||
});
|
||||
return { text: "done" };
|
||||
},
|
||||
});
|
||||
expect(firstToolResultPayload(dispatcher)).toMatchObject({
|
||||
text: "Progress updated",
|
||||
isStatusNotice: true,
|
||||
});
|
||||
expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("sends only one plan status notice per reply run", async () => {
|
||||
setNoAbort();
|
||||
const cfg = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,53 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { createChannelProgressDraftCompositor } from "./progress-draft-compositor.js";
|
||||
|
||||
describe("progress draft plan lifecycle", () => {
|
||||
it("keeps the native update callback's unformatted text contract", async () => {
|
||||
const tryNativeUpdate = vi.fn(() => Promise.resolve(true));
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: false, toolProgress: true } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "native-literal",
|
||||
update: () => true,
|
||||
tryNativeUpdate,
|
||||
});
|
||||
try {
|
||||
await progress.pushPlanProgress([], {
|
||||
explanation: "Use **literal**.",
|
||||
explanationFormat: "plain",
|
||||
});
|
||||
await progress.pushToolProgress("Reading", { startImmediately: true });
|
||||
expect(tryNativeUpdate).toHaveBeenCalledWith(expect.stringContaining("Use **literal**."));
|
||||
} finally {
|
||||
progress.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
it("repaints identical text when authored Markdown replaces a prepared note", async () => {
|
||||
const update = vi.fn(() => true);
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: false, commentary: false } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "literal-handoff",
|
||||
formatPlainText: (text) => text,
|
||||
update,
|
||||
});
|
||||
try {
|
||||
await progress.pushPlanProgress([], {
|
||||
explanation: "**literal**",
|
||||
explanationFormat: "plain",
|
||||
});
|
||||
expect(progress.getSnapshot().statusHeadlineFormat).toBe("plain");
|
||||
await progress.pushPreambleHeadline("**literal**");
|
||||
expect(update).toHaveBeenCalledTimes(2);
|
||||
expect(progress.getSnapshot().statusHeadline).toBe("**literal**");
|
||||
expect(progress.getSnapshot().statusHeadlineFormat).toBeUndefined();
|
||||
expect(progress.getSnapshot().planExplanationFormat).toBe("plain");
|
||||
} finally {
|
||||
progress.cancel();
|
||||
}
|
||||
});
|
||||
it.each(["partial", "block", "progress"] as const)(
|
||||
"preserves the plan across message and answer boundaries in %s mode",
|
||||
async (mode) => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type {
|
||||
ChannelProgressDraftCompositorLine,
|
||||
ChannelProgressDraftCompositorSnapshot,
|
||||
ChannelProgressDraftCompositorParams,
|
||||
} from "./progress-draft-compositor.types.js";
|
||||
import {
|
||||
createProgressDraftDiffStatTracker,
|
||||
formatChannelProgressDraftDiffStat,
|
||||
type ChannelProgressDraftDiffStat,
|
||||
} from "./progress-draft-diffstat.js";
|
||||
import {
|
||||
createChannelProgressDraftEventHandlers,
|
||||
type ChannelProgressDraftEventLineBuilder,
|
||||
} from "./progress-draft-events.js";
|
||||
import { createChannelProgressDraftEventHandlers } from "./progress-draft-events.js";
|
||||
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
|
||||
import {
|
||||
formatReasoningProgressDisplayLine,
|
||||
|
|
@ -32,8 +33,6 @@ import {
|
|||
resolveChannelStreamingProgressCommentary,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
type StreamingCompatEntry,
|
||||
type StreamingMode,
|
||||
} from "./streaming.js";
|
||||
|
||||
export { createChannelProgressWorkCounter } from "./progress-work-counter.js";
|
||||
|
|
@ -43,55 +42,12 @@ export { createChannelProgressWorkCounter } from "./progress-work-counter.js";
|
|||
// the narrator, deliberately not re-exported through the SDK barrels.
|
||||
export const PROGRESS_STATUS_PREAMBLE_FRESH_MS = 20_000;
|
||||
|
||||
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
|
||||
export type ChannelProgressDraftCompositorSnapshot = Readonly<{
|
||||
lines: readonly ChannelProgressDraftCompositorLine[];
|
||||
label?: string;
|
||||
statusHeadline?: string;
|
||||
plan?: readonly AgentPlanStep[];
|
||||
planExplanation?: string;
|
||||
diffStat?: ChannelProgressDraftDiffStat;
|
||||
}>;
|
||||
export type {
|
||||
ChannelProgressDraftCompositorLine,
|
||||
ChannelProgressDraftCompositorSnapshot,
|
||||
} from "./progress-draft-compositor.types.js";
|
||||
|
||||
type ChannelProgressDraftUpdateOptions = {
|
||||
flush?: boolean;
|
||||
lines: readonly ChannelProgressDraftCompositorLine[];
|
||||
snapshot: ChannelProgressDraftCompositorSnapshot;
|
||||
};
|
||||
|
||||
export function createChannelProgressDraftCompositor(params: {
|
||||
/** @deprecated v2026.9.1 SDK presentation; retain until a breaking SDK release. */
|
||||
presentation?: "summary";
|
||||
entry: StreamingCompatEntry | null | undefined;
|
||||
mode: StreamingMode;
|
||||
active: boolean;
|
||||
seed: string;
|
||||
update: (
|
||||
text: string,
|
||||
options: ChannelProgressDraftUpdateOptions,
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
deleteCurrent?: () => Promise<void> | void;
|
||||
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
|
||||
/** Publish when structured lines change even if the rendered text does not. */
|
||||
updateOnLineChange?: boolean;
|
||||
/**
|
||||
* Set when the channel renders `update`'s structured `lines` itself, so the
|
||||
* composed text carries only the status block (label, headline, checklist).
|
||||
*/
|
||||
rendersRollingLinesNatively?: boolean;
|
||||
formatLine?: (line: string) => string;
|
||||
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
|
||||
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
|
||||
reasoningLinePrefix?: string;
|
||||
commentaryLinePrefix?: string;
|
||||
reasoningGate?: boolean;
|
||||
commentaryItalics?: boolean;
|
||||
now?: () => number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
/** Channel-specific formatter policy; event/lifecycle ownership remains in the compositor. */
|
||||
buildProgressEventLine?: ChannelProgressDraftEventLineBuilder;
|
||||
}) {
|
||||
export function createChannelProgressDraftCompositor(params: ChannelProgressDraftCompositorParams) {
|
||||
const now = params.now ?? Date.now;
|
||||
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
|
||||
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
|
||||
|
|
@ -126,6 +82,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
let lines: ChannelProgressDraftCompositorLine[] = [];
|
||||
let renderGeneration = 0;
|
||||
let lastRenderedText = "";
|
||||
let lastRenderedStatusFormat: "plain" | undefined;
|
||||
let lastRenderedLines = lines;
|
||||
let lastRenderedDiffStatKey = "";
|
||||
let reasoningRawText = "";
|
||||
|
|
@ -143,6 +100,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
let narrationText = "";
|
||||
let planSteps: AgentPlanStep[] | undefined;
|
||||
let planExplanation = "";
|
||||
let planExplanationFormat: "plain" | undefined;
|
||||
let finalReplyStarted = false;
|
||||
let finalReplyDelivered = false;
|
||||
const diffStatTracker = createProgressDraftDiffStatTracker({
|
||||
|
|
@ -176,14 +134,23 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
const resolveStatusText = () => {
|
||||
const preambleIsFresh =
|
||||
preambleAt !== undefined && now() - preambleAt < PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||||
const effectiveNarration = narrationText || planExplanation;
|
||||
return preambleText && (preambleIsFresh || !effectiveNarration)
|
||||
? preambleText
|
||||
: effectiveNarration;
|
||||
if (preambleText && (preambleIsFresh || !(narrationText || planExplanation))) {
|
||||
return { text: preambleText };
|
||||
}
|
||||
return narrationText
|
||||
? { text: narrationText }
|
||||
: { text: planExplanation, format: planExplanationFormat };
|
||||
};
|
||||
|
||||
const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) => {
|
||||
const narration = resolveStatusText() || undefined;
|
||||
const formatDraftText = (
|
||||
draftLines = lines,
|
||||
options?: {
|
||||
formatted?: boolean;
|
||||
onPreparedBlocks?: (blocks: Array<{ text: string; format: "plain" | "markdown" }>) => void;
|
||||
},
|
||||
) => {
|
||||
const status = resolveStatusText();
|
||||
const narration = status.text || undefined;
|
||||
// Channels that render the rolling lines themselves (from `update`'s
|
||||
// `lines`) would print them twice if they also appeared in this text.
|
||||
const linesRenderedByChannel =
|
||||
|
|
@ -194,7 +161,10 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
lines: linesRenderedByChannel ? [] : draftLines,
|
||||
seed: params.seed,
|
||||
formatLine: options?.formatted === false ? undefined : params.formatLine,
|
||||
formatPlainText: options?.formatted === false ? (text) => text : params.formatPlainText,
|
||||
onPreparedBlocks: options?.onPreparedBlocks,
|
||||
narration,
|
||||
narrationFormat: status.format,
|
||||
plan: planSteps,
|
||||
diffStat: resolveDiffStat(),
|
||||
});
|
||||
|
|
@ -203,7 +173,8 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
const resolveDiffStat = diffStatTracker.resolve;
|
||||
|
||||
const getSnapshot = (): ChannelProgressDraftCompositorSnapshot => {
|
||||
const statusHeadline = resolveStatusText();
|
||||
const status = resolveStatusText();
|
||||
const statusHeadline = status.text;
|
||||
const diffStat = resolveDiffStat();
|
||||
const label = resolveChannelProgressDraftLabel({
|
||||
entry: params.entry,
|
||||
|
|
@ -214,8 +185,10 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
lines: lines.map((line) => (typeof line === "string" ? line : { ...line })),
|
||||
...(label ? { label } : {}),
|
||||
...(statusHeadline ? { statusHeadline } : {}),
|
||||
...(statusHeadline && status.format ? { statusHeadlineFormat: status.format } : {}),
|
||||
...(planSteps ? { plan: planSteps.map((entry) => ({ ...entry })) } : {}),
|
||||
...(planExplanation ? { planExplanation } : {}),
|
||||
...(planExplanation && planExplanationFormat ? { planExplanationFormat } : {}),
|
||||
...(diffStat ? { diffStat } : {}),
|
||||
};
|
||||
};
|
||||
|
|
@ -226,6 +199,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
lines = [];
|
||||
renderGeneration += 1;
|
||||
lastRenderedText = "";
|
||||
lastRenderedStatusFormat = undefined;
|
||||
lastRenderedLines = lines;
|
||||
lastRenderedDiffStatKey = "";
|
||||
reasoningRawText = "";
|
||||
|
|
@ -243,24 +217,44 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
clearActivityState(suppressed);
|
||||
planSteps = undefined;
|
||||
planExplanation = "";
|
||||
planExplanationFormat = undefined;
|
||||
};
|
||||
|
||||
const publish = async (options?: { flush?: boolean }): Promise<boolean> => {
|
||||
const text = formatDraftText();
|
||||
let blocks: Array<{ text: string; format: "plain" | "markdown" }> = [];
|
||||
const text = formatDraftText(lines, {
|
||||
onPreparedBlocks: (prepared) => {
|
||||
blocks = prepared;
|
||||
},
|
||||
});
|
||||
const statusFormat = resolveStatusText().format;
|
||||
const diffStatKey = JSON.stringify(resolveDiffStat() ?? null);
|
||||
const structuredStateChanged =
|
||||
params.updateOnLineChange === true &&
|
||||
(lines !== lastRenderedLines || diffStatKey !== lastRenderedDiffStatKey);
|
||||
if (!text || (text === lastRenderedText && !structuredStateChanged)) {
|
||||
if (
|
||||
!text ||
|
||||
(text === lastRenderedText &&
|
||||
statusFormat === lastRenderedStatusFormat &&
|
||||
!structuredStateChanged)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const generation = renderGeneration;
|
||||
const observed = await settleProgressVisibilityCallbackResult(
|
||||
params.update(text, { ...options, lines: [...lines], snapshot: getSnapshot() }),
|
||||
params.update(text, {
|
||||
...options,
|
||||
lines: [...lines],
|
||||
snapshot: {
|
||||
...getSnapshot(),
|
||||
...(blocks.some((block) => block.format === "plain") ? { preparedBlocks: blocks } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
// A retired publication can finish after its replacement; it no longer owns dedupe state.
|
||||
if (observed.visible && generation === renderGeneration) {
|
||||
lastRenderedText = text;
|
||||
lastRenderedStatusFormat = statusFormat;
|
||||
lastRenderedLines = lines;
|
||||
lastRenderedDiffStatKey = diffStatKey;
|
||||
}
|
||||
|
|
@ -332,7 +326,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
// Labels decorate activity; they must not keep a retracted card alive.
|
||||
if (
|
||||
lines.length ||
|
||||
resolveStatusText() ||
|
||||
resolveStatusText().text ||
|
||||
planSteps?.length ||
|
||||
formatChannelProgressDraftDiffStat(resolveDiffStat())
|
||||
) {
|
||||
|
|
@ -484,7 +478,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
return Boolean(lastRenderedText) && !finalReplyStarted && !finalReplyDelivered;
|
||||
},
|
||||
get hasStatusHeadline() {
|
||||
return Boolean(resolveStatusText());
|
||||
return Boolean(resolveStatusText().text);
|
||||
},
|
||||
get hasPlanProgress() {
|
||||
return Boolean(planSteps?.length);
|
||||
|
|
@ -573,7 +567,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
},
|
||||
async pushPlanProgress(
|
||||
steps?: AgentPlanStep[],
|
||||
options?: { explanation?: string },
|
||||
options?: { explanation?: string; explanationFormat?: "plain" },
|
||||
): Promise<boolean> {
|
||||
if (!params.active || progressSuppressed || finalReplyStarted || finalReplyDelivered) {
|
||||
return false;
|
||||
|
|
@ -583,6 +577,7 @@ export function createChannelProgressDraftCompositor(params: {
|
|||
}
|
||||
planSteps = steps && steps.length > 0 ? steps.map((entry) => ({ ...entry })) : undefined;
|
||||
planExplanation = options?.explanation?.replace(/\s+/g, " ").trim() ?? "";
|
||||
planExplanationFormat = options?.explanationFormat;
|
||||
if (!planSteps && !planExplanation) {
|
||||
return await renderAfterRetraction();
|
||||
}
|
||||
|
|
|
|||
62
src/channels/progress-draft-compositor.types.ts
Normal file
62
src/channels/progress-draft-compositor.types.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { ChannelProgressDraftDiffStat } from "./progress-draft-diffstat.js";
|
||||
import type { ChannelProgressDraftEventLineBuilder } from "./progress-draft-events.js";
|
||||
import type {
|
||||
AgentPlanStep,
|
||||
ChannelProgressDraftLine,
|
||||
StreamingCompatEntry,
|
||||
StreamingMode,
|
||||
} from "./streaming.js";
|
||||
|
||||
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
|
||||
export type ChannelProgressDraftCompositorSnapshot = Readonly<{
|
||||
lines: readonly ChannelProgressDraftCompositorLine[];
|
||||
label?: string;
|
||||
statusHeadline?: string;
|
||||
statusHeadlineFormat?: "plain";
|
||||
plan?: readonly AgentPlanStep[];
|
||||
planExplanation?: string;
|
||||
planExplanationFormat?: "plain";
|
||||
preparedBlocks?: readonly { text: string; format: "plain" | "markdown" }[];
|
||||
diffStat?: ChannelProgressDraftDiffStat;
|
||||
}>;
|
||||
|
||||
type ChannelProgressDraftUpdateOptions = {
|
||||
flush?: boolean;
|
||||
lines: readonly ChannelProgressDraftCompositorLine[];
|
||||
snapshot: ChannelProgressDraftCompositorSnapshot;
|
||||
};
|
||||
|
||||
export type ChannelProgressDraftCompositorParams = {
|
||||
/** @deprecated v2026.9.1 SDK presentation; retain until a breaking SDK release. */
|
||||
presentation?: "summary";
|
||||
entry: StreamingCompatEntry | null | undefined;
|
||||
mode: StreamingMode;
|
||||
active: boolean;
|
||||
seed: string;
|
||||
update: (
|
||||
text: string,
|
||||
options: ChannelProgressDraftUpdateOptions,
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
deleteCurrent?: () => Promise<void> | void;
|
||||
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
|
||||
/** Publish when structured lines change even if the rendered text does not. */
|
||||
updateOnLineChange?: boolean;
|
||||
/**
|
||||
* Set when the channel renders `update`'s structured `lines` itself, so the
|
||||
* composed text carries only the status block (label, headline, checklist).
|
||||
*/
|
||||
rendersRollingLinesNatively?: boolean;
|
||||
formatLine?: (line: string) => string;
|
||||
formatPlainText?: (text: string) => string;
|
||||
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
|
||||
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
|
||||
reasoningLinePrefix?: string;
|
||||
commentaryLinePrefix?: string;
|
||||
reasoningGate?: boolean;
|
||||
commentaryItalics?: boolean;
|
||||
now?: () => number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
/** Channel-specific formatter policy; event/lifecycle ownership remains in the compositor. */
|
||||
buildProgressEventLine?: ChannelProgressDraftEventLineBuilder;
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
import { isAgentPlanProgressToolName } from "../session-cards/progress-card-channel-summary.js";
|
||||
import { DEFAULT_PROGRESS_DRAFT_LABELS, selectProgressLabel } from "../shared/progress-labels.js";
|
||||
import { compactProgressText } from "../shared/text-truncate.js";
|
||||
import { escapeMarkdownText } from "../shared/text/escape-markdown.js";
|
||||
import { asBoolean } from "../utils/boolean.js";
|
||||
import {
|
||||
formatChannelProgressDraftDiffStat,
|
||||
|
|
@ -1406,10 +1407,15 @@ type ChannelProgressDraftTextParams = {
|
|||
random?: () => number;
|
||||
/** Optional formatter applied after line compaction. */
|
||||
formatLine?: (line: string) => string;
|
||||
/** Literal transport encoding, applied after compaction to prepared plain text. */
|
||||
formatPlainText?: (text: string) => string;
|
||||
/** Exposes the same ordered blocks to native renderers without parsing composed text. */
|
||||
onPreparedBlocks?: (blocks: Array<{ text: string; format: "plain" | "markdown" }>) => void;
|
||||
/** Prefix used for plain progress lines that lack their own icon. */
|
||||
bullet?: string;
|
||||
/** Status headline rendered above the plan and activity rows. */
|
||||
narration?: string;
|
||||
narrationFormat?: "plain";
|
||||
/** Latest full plan snapshot, rendered independently from rolling tool lines. */
|
||||
plan?: readonly AgentPlanStep[];
|
||||
diffStat?: ChannelProgressDraftDiffStat;
|
||||
|
|
@ -1450,7 +1456,11 @@ function formatProgressDraftText(
|
|||
});
|
||||
// The status headline sits above the rolling lines instead of replacing them:
|
||||
// a headline-only draft reads as "the agent is quiet" even while tools run.
|
||||
const statusHeadline = narration ? formatLine(narration) : "";
|
||||
const statusHeadline = narration
|
||||
? params.narrationFormat === "plain"
|
||||
? (params.formatPlainText ?? escapeMarkdownText)(narration)
|
||||
: formatLine(narration)
|
||||
: "";
|
||||
const bullet = params.bullet ?? "•";
|
||||
const toolLineBudget = planLines.length > 0 ? Math.max(0, maxLines - planLines.length) : maxLines;
|
||||
// Attention owns capacity before plans and routine progress consume the window.
|
||||
|
|
@ -1500,6 +1510,20 @@ function formatProgressDraftText(
|
|||
...planLines,
|
||||
...(diffStat ? [formatLine(compactChannelProgressDraftLine(diffStat, maxLineChars))] : []),
|
||||
].join("\n");
|
||||
const blocks: Array<{ text: string; format: "plain" | "markdown" }> = [];
|
||||
if (labelBlock) {
|
||||
blocks.push({ text: labelBlock, format: "markdown" });
|
||||
}
|
||||
if (statusHeadline) {
|
||||
blocks.push({
|
||||
text: params.narrationFormat === "plain" ? narration : statusHeadline,
|
||||
format: params.narrationFormat === "plain" ? "plain" : "markdown",
|
||||
});
|
||||
}
|
||||
if (rollingBlock) {
|
||||
blocks.push({ text: rollingBlock, format: "markdown" });
|
||||
}
|
||||
params.onPreparedBlocks?.(blocks);
|
||||
return [labelBlock, statusHeadline, rollingBlock].filter(Boolean).join("\n\n");
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
|
|
|||
|
|
@ -13,8 +13,40 @@ describe("projectProgressCardChannelUpdate", () => {
|
|||
},
|
||||
{
|
||||
name: "markdown-only",
|
||||
input: { markdown: "Working" },
|
||||
expected: { steps: [], explanation: "Progress updated" },
|
||||
input: { markdown: "Checking recent plan submissions and the tool's selection guidance." },
|
||||
expected: {
|
||||
steps: [],
|
||||
explanation: "Checking recent plan submissions and the tool's selection guidance.",
|
||||
explanationFormat: "plain",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "formatted note with renderer markup",
|
||||
input: {
|
||||
markdown:
|
||||
'<progress aria-label="Checks, 1/2" value="1" max="2"></progress>\n\n**Checking** [results](https://example.com).<br>Next step.<script>ignored()</script>',
|
||||
},
|
||||
expected: {
|
||||
steps: [],
|
||||
explanation: "Checking results. Next step.",
|
||||
explanationFormat: "plain",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "markup without visible text",
|
||||
input: { markdown: '<progress value="1" max="2"></progress>' },
|
||||
expected: { steps: [], explanation: "Progress updated", explanationFormat: "plain" },
|
||||
},
|
||||
{
|
||||
name: "checklist with a note",
|
||||
input: {
|
||||
markdown: "Checking the next task.",
|
||||
plan: [{ step: "Ship", status: "completed" }],
|
||||
},
|
||||
expected: {
|
||||
steps: [{ step: "Ship", status: "completed" }],
|
||||
explanation: "1/1 complete",
|
||||
},
|
||||
},
|
||||
{ name: "clear", input: {}, expected: { steps: [] } },
|
||||
{ name: "invalid array", input: [], expected: undefined },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { stripMarkdown } from "../shared/text/strip-markdown.js";
|
||||
import { normalizeProgressCardInput, ProgressCardInputError } from "./progress-card-input.js";
|
||||
|
||||
const PLAN_PROGRESS_TOOL_NAMES = new Set(["progress_card", "update_plan"]);
|
||||
|
|
@ -7,7 +8,7 @@ export function isAgentPlanProgressToolName(name: string | undefined): boolean {
|
|||
return PLAN_PROGRESS_TOOL_NAMES.has(name?.trim().toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
/** Projects durable card state without interpreting renderer-owned Markdown or HTML. */
|
||||
/** Projects checklist counts or readable notes through the shared Markdown owner. */
|
||||
export function projectProgressCardChannelUpdate(input: unknown) {
|
||||
const record = asOptionalRecord(input);
|
||||
if (!record) {
|
||||
|
|
@ -20,9 +21,15 @@ export function projectProgressCardChannelUpdate(input: unknown) {
|
|||
const explanation = steps.length
|
||||
? `${completed}/${steps.length} complete`
|
||||
: normalized.markdown
|
||||
? "Progress updated"
|
||||
? stripMarkdown(normalized.markdown, { linkStyle: "label", stripHtml: true })
|
||||
.replace(/\s+/g, " ")
|
||||
.trim() || "Progress updated"
|
||||
: undefined;
|
||||
return { steps, ...(explanation ? { explanation } : {}) };
|
||||
return {
|
||||
steps,
|
||||
...(explanation ? { explanation } : {}),
|
||||
...(!steps.length && explanation ? { explanationFormat: "plain" as const } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ProgressCardInputError) {
|
||||
return undefined;
|
||||
|
|
|
|||
4
src/shared/text/escape-markdown.ts
Normal file
4
src/shared/text/escape-markdown.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/** Encodes prepared text as literal CommonMark, including URL and HTML punctuation. */
|
||||
export function escapeMarkdownText(text: string): string {
|
||||
return text.replace(/[!-/:-@[-`{-~]/g, "\\$&");
|
||||
}
|
||||
29
src/shared/text/strip-markdown.test.ts
Normal file
29
src/shared/text/strip-markdown.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { stripMarkdown } from "./strip-markdown.js";
|
||||
|
||||
describe("stripMarkdown HTML projection", () => {
|
||||
it.each([
|
||||
["<b>Checking</b><br>results<hr>Next", "Checking\nresults\nNext"],
|
||||
["Before <!-- hidden <b>note</b> --> after", "Before after"],
|
||||
["Before <script>ignored()</script><style>hidden</style> after", "Before after"],
|
||||
["Before <script>\nconst tag = `</script>`;\nAfter", "Before `;\nAfter"],
|
||||
["Before <script>unfinished", "Before"],
|
||||
["Checking<style>\n\n.hidden { color: red }\n\n</style> results", "Checking results"],
|
||||
["Checking <!-- hidden\n\nprivate renderer note\n\n--> results", "Checking results"],
|
||||
["Use `<progress>` and <progress> literally", "Use <progress> and <progress> literally"],
|
||||
["Use \\<progress> literally", "Use <progress> literally"],
|
||||
["```html\n<script>example()</script>\n```", "<script>example()</script>"],
|
||||
['Checking [results](https://example.com "<script>") next.', "Checking results next."],
|
||||
[
|
||||
'Checking [results][r] next.\n\n[r]: https://example.com "<script>"',
|
||||
"Checking results next.",
|
||||
],
|
||||
[
|
||||
'Checking  next.',
|
||||
"Checking <progress> next.",
|
||||
],
|
||||
["Checking [<b>results</b>](https://example.com) next.", "Checking results next."],
|
||||
])("omits authored HTML while preserving visible text: %s", (input, expected) => {
|
||||
expect(stripMarkdown(input, { stripHtml: true, linkStyle: "label" })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { findAssistantTranscriptRoleHeaderSpans } from "../../../packages/markdo
|
|||
import { applyConstructFallbacks } from "../../../packages/markdown-core/src/construct-fallbacks.js";
|
||||
import type { FormatCapabilityProfile } from "../../../packages/markdown-core/src/format-capabilities.js";
|
||||
import { markdownToIR, type MarkdownIR } from "../../../packages/markdown-core/src/ir.js";
|
||||
import { stripHtmlFromMarkdown } from "../../../packages/markdown-core/src/strip-html.js";
|
||||
|
||||
type StripMarkdownOptions = {
|
||||
/** Mark parsed assistant transcript-role headers in transports without rich text. */
|
||||
|
|
@ -12,6 +13,8 @@ type StripMarkdownOptions = {
|
|||
linkStyle?: "label" | "label-and-url";
|
||||
/** Plain-text cleanup target. Speech removes decorative symbol and punctuation runs. */
|
||||
mode?: "plain-text" | "speech";
|
||||
/** Omit authored HTML tags and raw-text content while preserving code literals. */
|
||||
stripHtml?: boolean;
|
||||
};
|
||||
|
||||
type PlainTextInsertion = {
|
||||
|
|
@ -115,7 +118,7 @@ export function stripMarkdown(
|
|||
// The IR parser preserves links when role annotations are enabled so this
|
||||
// plain-text projection can still append explicit destinations. Direct rich
|
||||
// renderers suppress overlapping active links later at their own boundary.
|
||||
const ir = markdownToIR(text, {
|
||||
const ir = markdownToIR(options.stripHtml ? stripHtmlFromMarkdown(text) : text, {
|
||||
assistantTranscriptRoleHeaders: options.assistantTranscriptRoleHeaders,
|
||||
autolink: false,
|
||||
blockquotePrefix: "",
|
||||
|
|
|
|||
203
test/progress-card-channel-literals.integration.test.ts
Normal file
203
test/progress-card-channel-literals.integration.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDiscordDraftPreviewController,
|
||||
RequestClient,
|
||||
} from "../extensions/discord/test-api.js";
|
||||
import {
|
||||
renderTelegramProgressDraftPreview,
|
||||
telegramHtmlToPlainTextFallback,
|
||||
} from "../extensions/telegram/test-api.js";
|
||||
import { markdownToIR } from "../packages/markdown-core/src/ir.js";
|
||||
import { createSubscribedSessionHarness } from "../src/agents/embedded-agent-subscribe.e2e-harness.js";
|
||||
import { createOpenClawTools } from "../src/agents/openclaw-tools.js";
|
||||
import type { InProcessGatewayCaller } from "../src/agents/tools/in-process-gateway.js";
|
||||
import { createChannelProgressDraftCompositor } from "../src/channels/progress-draft-compositor.js";
|
||||
import { normalizeAgentPlanSteps } from "../src/channels/streaming.js";
|
||||
|
||||
const gatewayCall = vi.hoisted(() => vi.fn<InProcessGatewayCaller>());
|
||||
vi.mock("../src/agents/tools/in-process-gateway.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../src/agents/tools/in-process-gateway.js")>()),
|
||||
callInProcessGatewayTool: gatewayCall,
|
||||
}));
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
const cases = [
|
||||
{
|
||||
markdown: "Use `**literal**` now.",
|
||||
text: "Use **literal** now.",
|
||||
html: "Use **literal** now.",
|
||||
},
|
||||
{
|
||||
markdown: "Use `[label](https://example.com)` literally.",
|
||||
text: "Use [label](https://example.com) literally.",
|
||||
html: "Use [label](https://example.com) literally.",
|
||||
},
|
||||
{
|
||||
markdown: "Use `<b>literal</b>` now.",
|
||||
text: "Use <b>literal</b> now.",
|
||||
html: "Use <b>literal</b> now.",
|
||||
},
|
||||
{
|
||||
markdown: "Use <b>literal</b> now.",
|
||||
text: "Use <b>literal</b> now.",
|
||||
html: "Use <b>literal</b> now.",
|
||||
},
|
||||
];
|
||||
|
||||
async function runRegisteredCard(
|
||||
markdown: string,
|
||||
pushPlanProgress: ReturnType<typeof createChannelProgressDraftCompositor>["pushPlanProgress"],
|
||||
) {
|
||||
gatewayCall.mockResolvedValue({
|
||||
card: { sessionKey: "agent:main:literal-test", revision: 1, updatedAt: 1, markdown },
|
||||
});
|
||||
const tool = createOpenClawTools({
|
||||
agentSessionKey: "agent:main:literal-test",
|
||||
config: { tools: { allow: ["progress_card"] } },
|
||||
}).find((entry) => entry.name === "progress_card");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) {
|
||||
throw new Error("progress_card is not registered");
|
||||
}
|
||||
const { emit, subscription } = createSubscribedSessionHarness({
|
||||
runId: "literal-test",
|
||||
sessionPersistence: "detached",
|
||||
onAgentEvent: async (event) => {
|
||||
if (event.stream === "plan") {
|
||||
await pushPlanProgress(normalizeAgentPlanSteps(event.data.steps), {
|
||||
explanation:
|
||||
typeof event.data.explanation === "string" ? event.data.explanation : undefined,
|
||||
explanationFormat: event.data.explanationFormat === "plain" ? "plain" : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
emit({
|
||||
type: "tool_execution_start",
|
||||
toolName: tool.name,
|
||||
toolCallId: "card-1",
|
||||
args: { markdown },
|
||||
});
|
||||
await subscription.waitForPendingEvents();
|
||||
const result = await tool.execute("card-1", { markdown });
|
||||
emit({
|
||||
type: "tool_execution_end",
|
||||
toolName: tool.name,
|
||||
toolCallId: "card-1",
|
||||
result,
|
||||
isError: false,
|
||||
});
|
||||
await subscription.waitForPendingEvents();
|
||||
expect(gatewayCall).toHaveBeenCalledWith(
|
||||
"progressCard.put",
|
||||
expect.objectContaining({ markdown, sessionKey: "agent:main:literal-test" }),
|
||||
);
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
describe("registered progress cards at the final channel renderer", () => {
|
||||
it.each(cases)(
|
||||
"keeps Discord code and decoded text literal: $markdown",
|
||||
async ({ markdown, text }) => {
|
||||
const messages: string[] = [];
|
||||
const rest = new RequestClient("test-token", {
|
||||
queueRequests: false,
|
||||
fetch: async (_input, init) => {
|
||||
if (init?.method === "DELETE") {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("Expected a serialized message");
|
||||
}
|
||||
const body: unknown = JSON.parse(init.body);
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
!("content" in body) ||
|
||||
typeof body.content !== "string"
|
||||
) {
|
||||
throw new Error("Expected Discord message content");
|
||||
}
|
||||
messages.push(body.content);
|
||||
return Response.json({ id: "literal-draft" });
|
||||
},
|
||||
});
|
||||
const controller = createDiscordDraftPreviewController({
|
||||
cfg: {},
|
||||
discordConfig: { streaming: { mode: "progress", progress: { label: false } } },
|
||||
accountId: "default",
|
||||
sourceRepliesAreToolOnly: false,
|
||||
textLimit: 2000,
|
||||
deliveryRest: rest,
|
||||
deliverChannelId: "test-channel",
|
||||
replyReference: { peek: () => undefined },
|
||||
tableMode: "off",
|
||||
maxLinesPerMessage: undefined,
|
||||
chunkMode: "length",
|
||||
log: () => {},
|
||||
});
|
||||
try {
|
||||
await runRegisteredCard(markdown, controller.pushPlanProgress);
|
||||
await controller.flush();
|
||||
expect(messages).toHaveLength(1);
|
||||
const rendered = markdownToIR(messages[0] ?? "", { autolink: true, linkify: true });
|
||||
expect(rendered.text.trim()).toBe(text);
|
||||
expect(rendered.styles).toEqual([]);
|
||||
expect(rendered.links).toEqual([]);
|
||||
expect(messages[0]).not.toContain("https://");
|
||||
} finally {
|
||||
await controller.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
cases.flatMap(({ markdown, text, html }) =>
|
||||
[false, true].map((richMessages) => ({ markdown, text, html, richMessages })),
|
||||
),
|
||||
)(
|
||||
"keeps Telegram literal text and inactive links (rich=$richMessages): $markdown",
|
||||
async ({ markdown, text, html, richMessages }) => {
|
||||
const previews: ReturnType<typeof renderTelegramProgressDraftPreview>[] = [];
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: false } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "literal-test",
|
||||
update: (_text, { snapshot }) => {
|
||||
previews.push(
|
||||
renderTelegramProgressDraftPreview(snapshot, {
|
||||
richMessages,
|
||||
toolProgress: true,
|
||||
maxLines: 8,
|
||||
maxLineChars: 240,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runRegisteredCard(markdown, progress.pushPlanProgress);
|
||||
expect(previews).toHaveLength(1);
|
||||
const preview = previews[0];
|
||||
if (richMessages) {
|
||||
expect(preview?.text).toBe(text);
|
||||
expect(preview?.richMessage).toEqual({
|
||||
blocks: [{ type: "paragraph", text: { type: "code", text } }],
|
||||
skip_entity_detection: true,
|
||||
});
|
||||
} else {
|
||||
expect(preview?.text).toBe(`<code>${html}</code>`);
|
||||
expect(telegramHtmlToPlainTextFallback(preview?.text ?? "")).toBe(text);
|
||||
expect(preview?.text).not.toContain("href=");
|
||||
}
|
||||
} finally {
|
||||
progress.cancel();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue