mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 22:31:39 +00:00
Providers stream commentary as cumulative snapshots, and the draft line id was derived from the commentary text, so each snapshot minted a new id and stacked another line instead of replacing the open one. Id-less commentary now remembers its open line and reuses that id when the next snapshot continues it; a snapshot sanitized to nothing no longer clears the line, since only an explicit item id retracts. Also fixes activation: the commentary gate guessed "partial" when streaming.mode was unset, which made progress.commentary a silent no-op on channels whose own default is progress (Telegram, Discord). It now takes the caller's resolved mode, matching resolveChannelStreamingPreviewToolProgress. Proven live on Telegram: three overlapping commentary lines collapse to one.
1036 lines
36 KiB
TypeScript
1036 lines
36 KiB
TypeScript
// Progress draft compositor tests cover streamed draft composition for channel progress updates.
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import {
|
||
createChannelProgressDraftCompositor,
|
||
createChannelProgressReceiptTracker,
|
||
PROGRESS_STATUS_PREAMBLE_FRESH_MS,
|
||
} from "./progress-draft-compositor.js";
|
||
import { DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS } from "./streaming.js";
|
||
|
||
describe("createChannelProgressDraftCompositor", () => {
|
||
it("tracks compact per-turn progress receipts", () => {
|
||
let now = 1_000;
|
||
const receipt = createChannelProgressReceiptTracker({ now: () => now });
|
||
|
||
receipt.noteReasoning();
|
||
receipt.noteToolCall("exec");
|
||
receipt.noteCommentary("note-1", "First note");
|
||
receipt.noteCommentary("note-1", "Updated note");
|
||
receipt.noteReasoning();
|
||
now = 43_000;
|
||
|
||
expect(receipt.buildSummaryLine()).toBe("🧠 2 thoughts · 💬 1 note · 🛠️ 1 tool call · ⏱️ 42s");
|
||
|
||
receipt.reset();
|
||
now = 43_500;
|
||
expect(receipt.buildSummaryLine()).toBe("⏱️ 1s");
|
||
});
|
||
|
||
it("starts immediately for plans, replaces snapshots, and clears them on reset", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: false } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
await progress.pushPreambleHeadline("Implementing the change.");
|
||
await progress.pushPlanProgress([
|
||
{ step: "Inspect", status: "completed" },
|
||
{ step: "Patch", status: "in_progress" },
|
||
]);
|
||
|
||
expect(progress.hasStarted).toBe(true);
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Implementing the change.\n\n✅ Inspect\n▸ Patch",
|
||
expect.objectContaining({ flush: true }),
|
||
);
|
||
|
||
await progress.pushPlanProgress([{ step: "Test", status: "in_progress" }]);
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Implementing the change.\n\n▸ Test",
|
||
expect.anything(),
|
||
);
|
||
|
||
progress.reset();
|
||
await progress.pushToolProgress("🛠️ Next", { startImmediately: true });
|
||
expect(update).toHaveBeenLastCalledWith("🛠️ Next", expect.anything());
|
||
});
|
||
|
||
it("returns detached structured state for channel-native renderers", async () => {
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: false } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update: vi.fn(),
|
||
});
|
||
|
||
await progress.pushPreambleHeadline("Checking Slack.");
|
||
await progress.pushToolProgress(
|
||
{ id: "tool-call-1", kind: "tool", text: "🛠️ Exec", label: "Exec", toolName: "exec" },
|
||
{ startImmediately: true },
|
||
);
|
||
await progress.pushPlanProgress([{ step: "Patch", status: "in_progress" }], {
|
||
explanation: "Applying the change.",
|
||
});
|
||
|
||
const snapshot = progress.getSnapshot();
|
||
expect(snapshot).toEqual({
|
||
lines: [
|
||
{
|
||
id: "tool-call-1",
|
||
kind: "tool",
|
||
text: "🛠️ Exec",
|
||
label: "Exec",
|
||
toolName: "exec",
|
||
},
|
||
],
|
||
statusHeadline: "Checking Slack.",
|
||
plan: [{ step: "Patch", status: "in_progress" }],
|
||
planExplanation: "Applying the change.",
|
||
});
|
||
|
||
const snapshotLine = snapshot.lines[0];
|
||
if (typeof snapshotLine !== "object") {
|
||
throw new Error("expected structured snapshot line");
|
||
}
|
||
snapshotLine.text = "mutated";
|
||
snapshot.plan![0]!.step = "mutated";
|
||
expect(progress.getSnapshot()).toEqual({
|
||
lines: [
|
||
{
|
||
id: "tool-call-1",
|
||
kind: "tool",
|
||
text: "🛠️ Exec",
|
||
label: "Exec",
|
||
toolName: "exec",
|
||
},
|
||
],
|
||
statusHeadline: "Checking Slack.",
|
||
plan: [{ step: "Patch", status: "in_progress" }],
|
||
planExplanation: "Applying the change.",
|
||
});
|
||
});
|
||
|
||
it("keeps the progress label visible when tool lines are hidden", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: {
|
||
streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } },
|
||
},
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||
|
||
expect(update).toHaveBeenCalledWith("Shelling", { flush: true, lines: [] });
|
||
});
|
||
|
||
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" } },
|
||
},
|
||
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("shares reasoning merge state with legacy preview renderers", () => {
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "partial" } },
|
||
mode: "partial",
|
||
active: false,
|
||
seed: "test",
|
||
update: vi.fn(),
|
||
});
|
||
|
||
expect(progress.mergeReasoningProgress("Reading")).toBe("Reading");
|
||
expect(progress.mergeReasoningProgress(" the Slack handler")).toBe("Reading the Slack handler");
|
||
progress.resetReasoningProgress();
|
||
expect(progress.mergeReasoningProgress("Checking again")).toBe("Checking again");
|
||
});
|
||
|
||
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,
|
||
});
|
||
|
||
progress.markFinalReplyStarted();
|
||
progress.markFinalReplyDelivered();
|
||
expect(await progress.pushReasoningProgress("queued-turn thinking")).toBe(false);
|
||
|
||
// New assistant message boundary on a queued/followup turn.
|
||
expect(progress.beginNewTurn()).toBe(true);
|
||
expect(progress.hasStarted).toBe(false);
|
||
await progress.start();
|
||
await progress.pushReasoningProgress("queued-turn thinking", { snapshot: true });
|
||
|
||
expect(update).toHaveBeenCalled();
|
||
expect(progress.beginNewTurn()).toBe(false);
|
||
});
|
||
|
||
it("force-rearms an authoritative queued boundary without a prior final", 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("first turn", { startImmediately: true });
|
||
expect(progress.beginNewTurn()).toBe(false);
|
||
expect(progress.beginNewTurn({ force: true })).toBe(true);
|
||
await progress.pushToolProgress("queued turn", { startImmediately: true });
|
||
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n• queued turn", expect.anything());
|
||
});
|
||
|
||
it("cancels a delayed draft when the final reply starts", 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.pushToolProgress("🛠️ Exec");
|
||
progress.markFinalReplyStarted();
|
||
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS);
|
||
|
||
expect(progress.hasStarted).toBe(false);
|
||
expect(update).not.toHaveBeenCalled();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("does not resurrect progress after suppression", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
progress.suppress();
|
||
await progress.pushReasoningProgress("Reading files");
|
||
|
||
expect(update).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it("composes reasoning deltas with tool progress", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
reasoningLinePrefix: "🧠 ",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||
await progress.pushReasoningProgress("Reading");
|
||
await progress.pushReasoningProgress(" files");
|
||
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Reading files_", {
|
||
lines: ["🛠️ Exec", "🧠 _Reading files_"],
|
||
});
|
||
});
|
||
|
||
it("labels window narration with a 💬 prefix", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
commentaryLinePrefix: "💬 ",
|
||
update,
|
||
});
|
||
|
||
const rejected = await progress.pushCommentaryProgress(
|
||
"[[reply_to_current]] _NO_REPLY_ [[audio_as_voice]]",
|
||
{ itemId: "silent" },
|
||
);
|
||
const accepted = await progress.pushCommentaryProgress("Checking the workspace", {
|
||
itemId: "c1",
|
||
});
|
||
|
||
const rendered = update.mock.calls.map((call) => call[0]);
|
||
expect(rejected).toBe(false);
|
||
expect(accepted).toBe(true);
|
||
expect(rendered).toContain("Shelling\n\n💬 _Checking the workspace_");
|
||
});
|
||
|
||
it("collapses cumulative id-less commentary snapshots onto one line", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
commentaryLinePrefix: "💬 ",
|
||
update,
|
||
});
|
||
|
||
expect(await progress.pushCommentaryProgress("Checking")).toBe(true);
|
||
expect(await progress.pushCommentaryProgress("Checking the workspace")).toBe(true);
|
||
expect(await progress.pushCommentaryProgress("Checking the workspace before answering.")).toBe(
|
||
true,
|
||
);
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\n💬 _Checking the workspace before answering._",
|
||
{
|
||
lines: [
|
||
expect.objectContaining({
|
||
text: "💬 _Checking the workspace before answering._",
|
||
kind: "item",
|
||
label: "Commentary",
|
||
}),
|
||
],
|
||
},
|
||
);
|
||
expect(progress.getSnapshot().lines).toHaveLength(1);
|
||
});
|
||
|
||
it("appends genuinely distinct id-less commentary as separate lines", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
commentaryLinePrefix: "💬 ",
|
||
update,
|
||
});
|
||
|
||
expect(await progress.pushCommentaryProgress("Checking the workspace")).toBe(true);
|
||
expect(await progress.pushCommentaryProgress("Writing the patch next")).toBe(true);
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\n💬 _Checking the workspace_\n💬 _Writing the patch next_",
|
||
{
|
||
lines: [
|
||
expect.objectContaining({ text: "💬 _Checking the workspace_" }),
|
||
expect.objectContaining({ text: "💬 _Writing the patch next_" }),
|
||
],
|
||
},
|
||
);
|
||
});
|
||
|
||
it("updates an id-less commentary line in place after a later tool line", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
commentaryLinePrefix: "💬 ",
|
||
update,
|
||
});
|
||
|
||
await progress.pushCommentaryProgress("Checking");
|
||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||
await progress.pushCommentaryProgress("Checking the workspace");
|
||
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n💬 _Checking the workspace_\n🛠️ Exec", {
|
||
lines: [expect.objectContaining({ text: "💬 _Checking the workspace_" }), "🛠️ Exec"],
|
||
});
|
||
});
|
||
|
||
it("keeps id-less commentary when a later snapshot sanitizes to empty", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
commentaryLinePrefix: "💬 ",
|
||
update,
|
||
});
|
||
|
||
expect(await progress.pushCommentaryProgress("Checking the workspace")).toBe(true);
|
||
const callsAfterValid = update.mock.calls.length;
|
||
expect(
|
||
await progress.pushCommentaryProgress("[[reply_to_current]] _NO_REPLY_ [[audio_as_voice]]"),
|
||
).toBe(false);
|
||
|
||
expect(update).toHaveBeenCalledTimes(callsAfterValid);
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\n💬 _Checking the workspace_",
|
||
expect.objectContaining({
|
||
lines: [expect.objectContaining({ text: "💬 _Checking the workspace_" })],
|
||
}),
|
||
);
|
||
expect(progress.getSnapshot().lines).toHaveLength(1);
|
||
});
|
||
|
||
it("replaces and retracts commentary by itemId", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
commentaryLinePrefix: "💬 ",
|
||
update,
|
||
});
|
||
|
||
expect(await progress.pushCommentaryProgress("First note", { itemId: "c1" })).toBe(true);
|
||
expect(await progress.pushCommentaryProgress("Updated note", { itemId: "c1" })).toBe(true);
|
||
expect(await progress.pushCommentaryProgress("Other note", { itemId: "c2" })).toBe(true);
|
||
expect(progress.getSnapshot().lines).toHaveLength(2);
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\n💬 _Updated note_\n💬 _Other note_",
|
||
expect.objectContaining({
|
||
lines: [
|
||
expect.objectContaining({ id: "commentary:c1", text: "💬 _Updated note_" }),
|
||
expect.objectContaining({ id: "commentary:c2", text: "💬 _Other note_" }),
|
||
],
|
||
}),
|
||
);
|
||
|
||
expect(await progress.pushCommentaryProgress("", { itemId: "c1" })).toBe(false);
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\n💬 _Other note_",
|
||
expect.objectContaining({
|
||
lines: [expect.objectContaining({ id: "commentary:c2", text: "💬 _Other note_" })],
|
||
}),
|
||
);
|
||
});
|
||
|
||
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🧠 _Listing the workspace_\n🛠️ ls\n🧠 _Picking the largest_\n🛠️ wc",
|
||
{
|
||
lines: ["🧠 _Listing the workspace_", "🛠️ ls", "🧠 _Picking the largest_", "🛠️ wc"],
|
||
},
|
||
);
|
||
});
|
||
|
||
it("preserves tagged reasoning content without leaking tags", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
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_", {
|
||
lines: ["🛠️ Exec", "🧠 _Checking files_"],
|
||
});
|
||
});
|
||
|
||
it("waits for complete reasoning tags before showing tagged progress", 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 });
|
||
const calls = update.mock.calls.length;
|
||
await progress.pushReasoningProgress("<thin");
|
||
|
||
expect(update.mock.calls).toHaveLength(calls);
|
||
});
|
||
|
||
it("preserves partial reasoning tag buffers across deltas", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
reasoningLinePrefix: "🧠 ",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||
await progress.pushReasoningProgress("<thin");
|
||
await progress.pushReasoningProgress("k>Checking files</think>Final answer prose");
|
||
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🧠 _Checking files_", {
|
||
lines: ["🛠️ Exec", "🧠 _Checking files_"],
|
||
});
|
||
});
|
||
|
||
it("keeps literal reasoning tags inside code blocks", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
reasoningLinePrefix: "🧠 ",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||
await progress.pushReasoningProgress("```html\n<think>literal</think>\n```");
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\n🛠️ Exec\n🧠 _```html <think>literal</think> ```_",
|
||
{
|
||
lines: ["🛠️ Exec", "🧠 _```html <think>literal</think> ```_"],
|
||
},
|
||
);
|
||
});
|
||
|
||
it("replaces repeated formatted reasoning snapshots", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
reasoningLinePrefix: "🧠 ",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||
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_", {
|
||
lines: ["🛠️ Exec", "🧠 _Reading files_"],
|
||
});
|
||
});
|
||
|
||
it("keeps tool lines under narration and drops redundant edits", 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.pushNarrationProgress("Updating the config file now.");
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nUpdating the config file now.\n\n🛠️ Exec",
|
||
expect.anything(),
|
||
);
|
||
|
||
// Tool events stay visible under the headline, so each new line edits.
|
||
await progress.pushToolProgress("🛠️ Wc", { startImmediately: true });
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nUpdating the config file now.\n\n🛠️ Exec\n🛠️ Wc",
|
||
expect.anything(),
|
||
);
|
||
|
||
// Identical narration is dropped; changed narration edits once.
|
||
expect(await progress.pushNarrationProgress("Updating the config file now.")).toBe(false);
|
||
await progress.pushNarrationProgress("Restarting the gateway.");
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nRestarting the gateway.\n\n🛠️ Exec\n🛠️ Wc",
|
||
expect.anything(),
|
||
);
|
||
|
||
// Narration stopping (empty update) leaves the raw tool lines.
|
||
await progress.pushNarrationProgress("");
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n🛠️ Wc", expect.anything());
|
||
});
|
||
|
||
it("hands preambles to the commentary lane when it is enabled", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: {
|
||
streaming: { mode: "progress", progress: { label: "Shelling", commentary: true } },
|
||
},
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
// The opt-in 💬 lane renders every preamble as an interleaved line; the
|
||
// headline must decline so it cannot replace those documented lines.
|
||
expect(await progress.pushPreambleHeadline("Reading the workspace.")).toBe(false);
|
||
expect(progress.hasStatusHeadline).toBe(false);
|
||
});
|
||
|
||
it("holds a preamble headline until the gate starts and hides the implicit label", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress" } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
expect(await progress.pushPreambleHeadline(" Reading\n the workspace. ")).toBe(false);
|
||
expect(await progress.pushPreambleHeadline(" ")).toBe(false);
|
||
expect(progress.hasStarted).toBe(false);
|
||
expect(update).not.toHaveBeenCalled();
|
||
|
||
await progress.start();
|
||
|
||
expect(update).toHaveBeenCalledWith("Reading the workspace.", { flush: true, lines: [] });
|
||
});
|
||
|
||
it("publishes rolling tool-line changes beneath a stable preamble headline", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { maxLines: 8 } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
updateOnLineChange: true,
|
||
update,
|
||
});
|
||
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
await progress.pushToolProgress("🛠️ Exec one", { startImmediately: true });
|
||
await progress.pushToolProgress("🛠️ Exec two", { startImmediately: true });
|
||
|
||
expect(update).toHaveBeenLastCalledWith("Reading the workspace.\n\n🛠️ Exec one\n🛠️ Exec two", {
|
||
lines: ["🛠️ Exec one", "🛠️ Exec two"],
|
||
});
|
||
});
|
||
|
||
it("rejects control-only preambles without clobbering a valid headline", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress" } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
expect(progress.hasStatusHeadline).toBe(false);
|
||
expect(await progress.pushPreambleHeadline("[[reply_to_current]]")).toBe(false);
|
||
expect(progress.hasStatusHeadline).toBe(false);
|
||
await progress.pushPreambleHeadline(
|
||
"[[reply_to_current]] Reading the workspace. [[audio_as_voice]]",
|
||
);
|
||
expect(progress.hasStatusHeadline).toBe(true);
|
||
await progress.start();
|
||
expect(update).toHaveBeenLastCalledWith("Reading the workspace.", {
|
||
flush: true,
|
||
lines: [],
|
||
});
|
||
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||
const calls = update.mock.calls.length;
|
||
expect(await progress.pushPreambleHeadline("[[reply_to_current]]")).toBe(false);
|
||
expect(
|
||
await progress.pushPreambleHeadline("[[reply_to_current]] ~~NO_REPLY~~ [[audio_as_voice]]"),
|
||
).toBe(false);
|
||
expect(progress.hasStatusHeadline).toBe(true);
|
||
expect(update).toHaveBeenCalledTimes(calls);
|
||
|
||
await progress.pushNarrationProgress("Utility filler.");
|
||
expect(update).toHaveBeenLastCalledWith("Utility filler.", expect.anything());
|
||
await progress.pushNarrationProgress("");
|
||
expect(update).toHaveBeenLastCalledWith("Reading the workspace.", expect.anything());
|
||
});
|
||
|
||
it("retracts only the matching preamble headline", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.", { itemId: "preamble-1" });
|
||
await progress.pushPreambleHeadline("Checking the config.", { itemId: "preamble-2" });
|
||
const callsBeforeStaleRetraction = update.mock.calls.length;
|
||
|
||
expect(await progress.pushPreambleHeadline("", { itemId: "preamble-1" })).toBe(false);
|
||
expect(update).toHaveBeenCalledTimes(callsBeforeStaleRetraction);
|
||
expect(progress.hasStatusHeadline).toBe(true);
|
||
|
||
expect(await progress.pushPreambleHeadline("", { itemId: "preamble-2" })).toBe(true);
|
||
expect(progress.hasStatusHeadline).toBe(false);
|
||
expect(update).toHaveBeenLastCalledWith("Shelling", expect.anything());
|
||
});
|
||
|
||
it("keeps a fresh preamble ahead of later narration", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS - 1;
|
||
await progress.pushNarrationProgress("Utility narration should wait.");
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nReading the workspace.",
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("uses newer narration after the preamble becomes stale", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||
await progress.pushNarrationProgress("Comparing the configuration now.");
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nComparing the configuration now.",
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("uses a plan explanation after the preamble becomes stale", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||
await progress.pushPlanProgress([{ step: "Patch", status: "in_progress" }], {
|
||
explanation: "Applying the revised plan.",
|
||
});
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nApplying the revised plan.\n\n▸ Patch",
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("refreshes a new preamble item when its text matches the stale item", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.", { itemId: "first" });
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||
await progress.pushNarrationProgress("Comparing the configuration now.");
|
||
await progress.pushPreambleHeadline("Reading the workspace.", { itemId: "second" });
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nReading the workspace.",
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("refreshes to retained narration when a visible preamble expires", 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.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
await progress.pushNarrationProgress("Comparing the configuration now.");
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nReading the workspace.",
|
||
expect.anything(),
|
||
);
|
||
|
||
await vi.advanceTimersByTimeAsync(PROGRESS_STATUS_PREAMBLE_FRESH_MS);
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nComparing the configuration now.",
|
||
expect.anything(),
|
||
);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("cancels a pending preamble-expiry refresh when the final starts", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress" } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update: vi.fn(),
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
await progress.pushNarrationProgress("Comparing the configuration now.");
|
||
expect(vi.getTimerCount()).toBe(1);
|
||
|
||
progress.markFinalReplyStarted();
|
||
expect(vi.getTimerCount()).toBe(0);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("returns to the retained preamble when narration clears", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||
await progress.pushNarrationProgress("Comparing the configuration now.");
|
||
await progress.pushNarrationProgress("");
|
||
|
||
expect(update).toHaveBeenLastCalledWith(
|
||
"Shelling\n\nReading the workspace.",
|
||
expect.anything(),
|
||
);
|
||
});
|
||
|
||
it("clears both status sources on reset", async () => {
|
||
let nowMs = 0;
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
now: () => nowMs,
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
await progress.pushPreambleHeadline("Reading the workspace.");
|
||
nowMs += PROGRESS_STATUS_PREAMBLE_FRESH_MS;
|
||
await progress.pushNarrationProgress("Comparing the configuration now.");
|
||
progress.reset();
|
||
await progress.pushToolProgress("🛠️ Next", { startImmediately: true });
|
||
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Next", expect.anything());
|
||
});
|
||
|
||
it("holds narration behind the initial progress delay", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress" } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec");
|
||
await progress.pushNarrationProgress("Reading the gateway config.");
|
||
|
||
expect(update).not.toHaveBeenCalled();
|
||
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS - 1);
|
||
expect(update).not.toHaveBeenCalled();
|
||
|
||
await vi.advanceTimersByTimeAsync(1);
|
||
expect(update).toHaveBeenCalledWith("Reading the gateway config.\n\n🛠️ Exec", {
|
||
flush: true,
|
||
lines: ["🛠️ Exec"],
|
||
});
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("ignores status updates once the final reply started and clears both per turn", async () => {
|
||
const update = vi.fn();
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
await progress.start();
|
||
expect(progress.isVisible).toBe(true);
|
||
await progress.pushPreambleHeadline("Checking the primary turn.");
|
||
await progress.pushNarrationProgress("Working on it.");
|
||
progress.markFinalReplyStarted();
|
||
expect(progress.isVisible).toBe(false);
|
||
expect(await progress.pushPreambleHeadline("Too late.")).toBe(false);
|
||
expect(await progress.pushNarrationProgress("Too late.")).toBe(false);
|
||
|
||
progress.markFinalReplyDelivered();
|
||
progress.beginNewTurn();
|
||
await progress.pushToolProgress("🛠️ Next", { startImmediately: true });
|
||
// The queued turn starts without either primary-turn status source.
|
||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Next", expect.anything());
|
||
});
|
||
|
||
it("logs a timer-fired start failure via the gate's default boundary logger", async () => {
|
||
vi.useFakeTimers();
|
||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
try {
|
||
const error = new Error("send failed");
|
||
const update = vi.fn().mockRejectedValue(error);
|
||
const progress = createChannelProgressDraftCompositor({
|
||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||
mode: "progress",
|
||
active: true,
|
||
seed: "test",
|
||
update,
|
||
});
|
||
|
||
await progress.pushToolProgress("🛠️ Exec");
|
||
expect(warn).not.toHaveBeenCalled();
|
||
|
||
await vi.advanceTimersByTimeAsync(DEFAULT_PROGRESS_DRAFT_INITIAL_DELAY_MS);
|
||
|
||
expect(update).toHaveBeenCalled();
|
||
expect(warn).toHaveBeenCalledWith(
|
||
"[progress-draft] channel progress draft failed to start: Error: send failed",
|
||
);
|
||
} finally {
|
||
vi.useRealTimers();
|
||
warn.mockRestore();
|
||
}
|
||
});
|
||
});
|