Fold a run's step messages behind its latest message (#188)
* Fold a run's step messages behind its latest message Long tool runs posted every step as its own bubble, so the answer drowned in narration and a summary written before saving a file appeared twice. Web and mobile now fold a run's text-only step messages behind one "N steps" line above the run's latest message. The thread snapshot also stops replaying live narration that was already posted, which showed steps again after a refresh. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ * Keep a run's last step visible above a card A card such as the peer-message receipt has no words of its own, so folding the narration into it hid everything the bot said. Steps now fold only into a written answer or the live row; before a card the last step stays visible. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dd177f7ef5
commit
5cf9d679b4
@ -211,7 +211,13 @@ describe("threadSnapshot", () => {
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
type: {
|
||||
in: ["thread.progress", "thread.subagent", "agent.tool.called", "agent.tool.completed"],
|
||||
in: [
|
||||
"thread.message.created",
|
||||
"thread.progress",
|
||||
"thread.subagent",
|
||||
"agent.tool.called",
|
||||
"agent.tool.completed",
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@ -230,6 +236,69 @@ describe("threadSnapshot", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reload live narration that was already posted as a message", async () => {
|
||||
const run = {
|
||||
id: "run-1",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
taskId: "task-1",
|
||||
status: "running",
|
||||
trigger: "user",
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date("2026-08-23T00:00:00.000Z"),
|
||||
};
|
||||
const at = new Date("2026-08-23T00:00:00.000Z");
|
||||
const event = (seq: number, type: string, payload: Record<string, unknown>) => ({
|
||||
id: `event-${seq}`,
|
||||
threadId: "thread-1",
|
||||
botId: "bot-1",
|
||||
seq,
|
||||
type,
|
||||
runId: "run-1",
|
||||
payload,
|
||||
createdAt: at,
|
||||
});
|
||||
const tx = {
|
||||
$queryRaw: threadQueries(),
|
||||
message: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
event: {
|
||||
findFirst: vi.fn().mockResolvedValue({ seq: 6 }),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
event(4, "thread.progress", { text: "Opening the page." }),
|
||||
event(5, "thread.message.created", {
|
||||
messageId: "message-1",
|
||||
role: "bot",
|
||||
blocks: [{ kind: "text", text: "Opening the page." }],
|
||||
}),
|
||||
event(6, "thread.progress", { text: "Writing the summary." }),
|
||||
]),
|
||||
},
|
||||
run: { findFirst: botRunFindFirst([run]) },
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: vi.fn(async (callback: (client: typeof tx) => unknown) => callback(tx)),
|
||||
} as unknown as PrismaClient;
|
||||
const target = {
|
||||
kind: "bot",
|
||||
botId: "bot-1",
|
||||
threadId: "thread-1",
|
||||
bot: { computer: null },
|
||||
} as ThreadTarget;
|
||||
|
||||
const snapshot = await threadSnapshot({ prisma }, target);
|
||||
|
||||
expect(snapshot.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "progress:run-1",
|
||||
blocks: [{ kind: "progress", text: "Writing the summary." }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the latest failed run so the client can show its error", async () => {
|
||||
const run = {
|
||||
id: "run-failed",
|
||||
|
||||
@ -491,6 +491,7 @@ export async function threadSnapshot(
|
||||
runId: currentRun.id,
|
||||
type: {
|
||||
in: [
|
||||
"thread.message.created",
|
||||
"thread.progress",
|
||||
"thread.subagent",
|
||||
"agent.tool.called",
|
||||
@ -556,6 +557,7 @@ export async function threadSnapshot(
|
||||
runId: { in: activeRuns.map((run) => run.id) },
|
||||
type: {
|
||||
in: [
|
||||
"thread.message.created",
|
||||
"thread.progress",
|
||||
"thread.subagent",
|
||||
"agent.tool.called",
|
||||
@ -631,8 +633,12 @@ function messagesWithLiveEvents(
|
||||
persisted: ThreadSnapshot["messages"],
|
||||
liveEvents: Parameters<typeof projectMessages>[0],
|
||||
) {
|
||||
// Message events only clear the live text they promoted; the persisted rows already carry them.
|
||||
const live = projectMessages(liveEvents).filter((message) => {
|
||||
if (message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")) {
|
||||
if (
|
||||
message.id.startsWith("progress:") &&
|
||||
message.blocks.some((block) => block.kind === "progress" || block.kind === "steps")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (!message.id.startsWith("subagent:")) return false;
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
type ComposerMention,
|
||||
cloudAgentHttpsUrl,
|
||||
deriveWorkspaceFocus,
|
||||
foldRunNarration,
|
||||
isApprovalAskBlock,
|
||||
isRoutineEventMessage,
|
||||
isRunTerminalEvent,
|
||||
@ -77,6 +78,7 @@ import {
|
||||
} from "../components/markdown-artifact-preview";
|
||||
import { NativeSymbol } from "../components/native-symbol";
|
||||
import { RoutineEventLine } from "../components/routine-event-line";
|
||||
import { RunSteps } from "../components/run-steps";
|
||||
import { ThreadCard } from "../components/thread-card";
|
||||
import type {
|
||||
MobileBot,
|
||||
@ -343,7 +345,14 @@ function Thread() {
|
||||
),
|
||||
[snap?.messages],
|
||||
);
|
||||
const visibleMessages = reactionView.visibleMessages;
|
||||
const runNarration = useMemo(
|
||||
() => foldRunNarration(reactionView.visibleMessages),
|
||||
[reactionView.visibleMessages],
|
||||
);
|
||||
const visibleMessages = useMemo(
|
||||
() => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)),
|
||||
[reactionView.visibleMessages, runNarration],
|
||||
);
|
||||
const latestMessageId = visibleMessages.at(-1)?.id ?? null;
|
||||
const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey);
|
||||
const composerMentionTargets = useMemo(
|
||||
@ -1484,6 +1493,7 @@ function Thread() {
|
||||
function renderMessageRow(message: MobileMessage, options?: { enableJump?: boolean }) {
|
||||
const actionProps = messageActionProps(message);
|
||||
const messageReactions = reactionView.reactions.get(message.id);
|
||||
const foldedSteps = runNarration.steps.get(message.id);
|
||||
const activityBotId =
|
||||
!inGroup && message.role === "bot" && message.id.startsWith("progress:")
|
||||
? (message.botId ?? botId)
|
||||
@ -1543,6 +1553,7 @@ function Thread() {
|
||||
flexShrink: 1,
|
||||
}}
|
||||
>
|
||||
{foldedSteps ? <RunSteps steps={foldedSteps} /> : null}
|
||||
<Pressable accessible={false} onLongPress={actionProps.onLongPress}>
|
||||
<MessageBubble
|
||||
botId={botId ?? snap?.members?.[0]?.botId ?? ""}
|
||||
|
||||
52
apps/mobile/components/run-steps.tsx
Normal file
52
apps/mobile/components/run-steps.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { MobileMessage } from "../lib/api";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useMobileTokens } from "../lib/native";
|
||||
import { NativeSymbol } from "./native-symbol";
|
||||
|
||||
/** A run's earlier step messages, folded behind one line above its latest message. */
|
||||
export function RunSteps({ steps }: { steps: readonly MobileMessage[] }) {
|
||||
const tokens = useMobileTokens();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = steps.length === 1 ? t("1 step") : t("{count} steps", { count: steps.length });
|
||||
return (
|
||||
<View style={{ marginBottom: 6 }}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: open }}
|
||||
onPress={() => setOpen((current) => !current)}
|
||||
hitSlop={8}
|
||||
style={{ flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" }}
|
||||
>
|
||||
<NativeSymbol
|
||||
ios={open ? "chevron.down" : "chevron.right"}
|
||||
android={open ? "chevron-down" : "chevron-forward"}
|
||||
size={12}
|
||||
color={tokens.mutedForeground}
|
||||
/>
|
||||
<Text style={{ color: tokens.mutedForeground, fontSize: 13.5 }}>{label}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View
|
||||
style={{
|
||||
marginTop: 4,
|
||||
gap: 6,
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: tokens.border,
|
||||
paddingLeft: 10,
|
||||
}}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Text key={step.id} style={{ color: tokens.mutedForeground, fontSize: 14 }}>
|
||||
{step.blocks
|
||||
.flatMap((block) => (block.kind === "text" ? [block.text] : []))
|
||||
.join("\n")}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
24
apps/web/e2e/run-steps.spec.ts
Normal file
24
apps/web/e2e/run-steps.spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } from "./helpers";
|
||||
|
||||
test("a long run shows its answer with the earlier steps folded", async ({ page }, testInfo) => {
|
||||
const stamp = Date.now();
|
||||
await signup(page, `run-steps-${stamp}@rakazo.test`, "password12", "Steps");
|
||||
await completeOnboarding(page);
|
||||
await waitForChatComposer(page);
|
||||
const composer = page.getByRole("combobox", { name: /Message|Nachricht/i });
|
||||
await composer.fill("Show your steps");
|
||||
await composer.press("Enter");
|
||||
|
||||
const transcript = page.getByTestId("transcript");
|
||||
await expect(transcript.getByText("Here is the summary.")).toBeVisible({ timeout: 60_000 });
|
||||
const steps = transcript.getByTestId("run-steps");
|
||||
await expect(steps).toHaveCount(1);
|
||||
await expect(transcript.getByText("Opening the page.")).toHaveCount(0);
|
||||
await captureScreenshot(page, testInfo, "run-steps-folded");
|
||||
|
||||
await steps.getByRole("button", { name: "2 steps" }).click();
|
||||
await expect(steps.getByText("Opening the page.")).toBeVisible();
|
||||
await expect(steps.getByText("Reading the article.")).toBeVisible();
|
||||
await captureScreenshot(page, testInfo, "run-steps-open");
|
||||
});
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}"
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr "{0, plural, one {# Schritt} other {# Schritte}}"
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# model} other {# models}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr "{0, plural, one {# step} other {# steps}}"
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# model} other {# model}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
@ -42,6 +42,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}"
|
||||
msgid "{0, plural, one {# more waiting} other {# more waiting}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: steps.length
|
||||
#: src/pages/shell/run-steps.tsx
|
||||
msgid "{0, plural, one {# step} other {# steps}}"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: habit.streak
|
||||
#: src/components/heute/HeuteCoach.tsx
|
||||
msgid "{0, plural, one {# week} other {# weeks}}"
|
||||
|
||||
39
apps/web/src/pages/shell/run-steps.tsx
Normal file
39
apps/web/src/pages/shell/run-steps.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Plural } from "@lingui/react/macro";
|
||||
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||||
import type { ThreadMessage } from "@rakazo/contracts";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
/** A run's earlier step messages, folded behind one line above its latest message. */
|
||||
export function RunSteps({ steps }: { steps: readonly ThreadMessage[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div data-testid="run-steps" className="mb-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-ui-body-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={`size-icon-sm transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<Plural value={steps.length} one="# step" other="# steps" />
|
||||
</button>
|
||||
{open ? (
|
||||
<ol className="mt-1 space-y-1.5 border-s border-border ps-3 text-ui-body-sm text-muted-foreground">
|
||||
{steps.map((step) => (
|
||||
<li key={step.id} data-message-id={step.id} dir="auto">
|
||||
{step.blocks.map((block, index) =>
|
||||
block.kind === "text" ? (
|
||||
<ChatMarkdown key={index}>{block.text}</ChatMarkdown>
|
||||
) : null,
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ import type { CardAct, MessageReaction, ThreadMessage } from "@rakazo/contracts"
|
||||
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
|
||||
import type { WorkspaceArtifact } from "@rakazo/core";
|
||||
import {
|
||||
foldRunNarration,
|
||||
isPeerReceiptBlocks,
|
||||
isRoutineEventMessage,
|
||||
isToolActivityBlock,
|
||||
@ -56,6 +57,7 @@ import {
|
||||
} from "./message-cards";
|
||||
import { previewMessageText } from "./message-preview";
|
||||
import { RoutineEventLine } from "./routine-event-line";
|
||||
import { RunSteps } from "./run-steps";
|
||||
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
||||
|
||||
export const Transcript = memo(function Transcript({
|
||||
@ -137,6 +139,10 @@ export const Transcript = memo(function Transcript({
|
||||
// Onboarding notes are worded in the UI language.
|
||||
[messages, i18n.locale],
|
||||
);
|
||||
const runNarration = useMemo(
|
||||
() => foldRunNarration(reactionView.visibleMessages),
|
||||
[reactionView.visibleMessages],
|
||||
);
|
||||
const transcriptSubordinate = encounterMode && voiceEncounterActive;
|
||||
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
|
||||
const primaryRunStatus = workingBots[0]?.status;
|
||||
@ -370,6 +376,8 @@ export const Transcript = memo(function Transcript({
|
||||
>
|
||||
{reactionView.visibleMessages.map((message) => {
|
||||
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
|
||||
if (runNarration.hidden.has(message.id)) return null;
|
||||
const foldedSteps = runNarration.steps.get(message.id);
|
||||
const peerReceipt = isPeerReceiptBlocks(message.blocks);
|
||||
if (isRoutineEventMessage(message.blocks)) {
|
||||
return (
|
||||
@ -394,6 +402,7 @@ export const Transcript = memo(function Transcript({
|
||||
data-quotable={message.id.includes(":") ? undefined : ""}
|
||||
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
|
||||
>
|
||||
{foldedSteps ? <RunSteps steps={foldedSteps} /> : null}
|
||||
{!peerReceipt && !message.id.startsWith("progress:") ? (
|
||||
<time
|
||||
dateTime={message.createdAt}
|
||||
|
||||
@ -261,6 +261,15 @@ export function inferScript(
|
||||
const coach = inferCoachScript(prompt);
|
||||
if (coach) return coach;
|
||||
// Said in passing: the bot sets the routine up without asking first.
|
||||
// A run that narrates two steps before its answer, as long tool runs do.
|
||||
if (lower.includes("show your steps")) {
|
||||
const listSchedules = { name: "schedule_list", args: {} };
|
||||
return [
|
||||
{ assistant: "Opening the page.", toolCalls: [listSchedules] },
|
||||
{ assistant: "Reading the article.", toolCalls: [listSchedules] },
|
||||
{ assistant: "Here is the summary.", complete: true },
|
||||
];
|
||||
}
|
||||
if (lower.includes("every sunday i plan the week")) {
|
||||
return [
|
||||
{
|
||||
|
||||
@ -40,6 +40,7 @@ export * from "./oo-rig.js";
|
||||
export * from "./quiet-hours.js";
|
||||
export * from "./response-bytes.js";
|
||||
export * from "./routine-events.js";
|
||||
export * from "./run-narration.js";
|
||||
export * from "./run-state.js";
|
||||
export * from "./sandbox-command.js";
|
||||
export * from "./screen-lease.js";
|
||||
|
||||
58
packages/core/src/run-narration.test.ts
Normal file
58
packages/core/src/run-narration.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { ThreadMessage } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { foldRunNarration } from "./run-narration.js";
|
||||
|
||||
function bot(
|
||||
id: string,
|
||||
runId: string | undefined,
|
||||
blocks: ThreadMessage["blocks"],
|
||||
): ThreadMessage {
|
||||
return {
|
||||
id,
|
||||
threadId: "thread-1",
|
||||
seq: 0,
|
||||
role: "bot",
|
||||
blocks,
|
||||
botId: "bot-1",
|
||||
...(runId ? { runId } : {}),
|
||||
createdAt: "2026-10-09T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
const text = (value: string): ThreadMessage["blocks"] => [{ kind: "text", text: value }];
|
||||
|
||||
describe("foldRunNarration", () => {
|
||||
it("folds a run's steps into its final message", () => {
|
||||
const { steps, hidden } = foldRunNarration([
|
||||
{ ...bot("user-1", undefined, text("Summarize the page")), role: "user" },
|
||||
bot("step-1", "run-1", text("Opening the page.")),
|
||||
bot("tools", "run-1", [{ kind: "steps", steps: [{ label: "Browser", count: 2 }] }]),
|
||||
bot("step-2", "run-1", text("Reading it.")),
|
||||
bot("final", "run-1", text("Here is the summary.")),
|
||||
]);
|
||||
expect(steps.get("final")?.map((message) => message.id)).toEqual(["step-1", "step-2"]);
|
||||
expect([...hidden]).toEqual(["step-1", "step-2"]);
|
||||
expect(steps.has("step-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("folds finished steps into the live message while the run works", () => {
|
||||
const { steps } = foldRunNarration([
|
||||
bot("step-1", "run-1", text("Opening the page.")),
|
||||
bot("progress:run-1", "run-1", [{ kind: "progress", text: "Reading" }]),
|
||||
]);
|
||||
expect(steps.get("progress:run-1")?.map((message) => message.id)).toEqual(["step-1"]);
|
||||
});
|
||||
|
||||
it("keeps other runs, cards and lone answers as they are", () => {
|
||||
const { steps, hidden } = foldRunNarration([
|
||||
bot("answer-1", "run-1", text("First answer.")),
|
||||
bot("answer-2", "run-2", text("Second answer.")),
|
||||
bot("step-1", "run-3", text("Checking.")),
|
||||
bot("step-2", "run-3", text("Messaging the researcher.")),
|
||||
bot("card", "run-3", [{ kind: "ask", text: "Which file?" }]),
|
||||
bot("after-card", "run-3", text("Done.")),
|
||||
]);
|
||||
// The step right before a card stays visible and carries the earlier one.
|
||||
expect(hidden).toEqual(new Set(["step-1"]));
|
||||
expect([...steps.keys()]).toEqual(["step-2"]);
|
||||
});
|
||||
});
|
||||
74
packages/core/src/run-narration.ts
Normal file
74
packages/core/src/run-narration.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type { MessageBlock } from "@rakazo/contracts";
|
||||
import { isToolActivityBlock } from "./tool-activity.js";
|
||||
|
||||
type RunMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
runId?: string;
|
||||
botId?: string;
|
||||
blocks: readonly MessageBlock[];
|
||||
};
|
||||
|
||||
/** A bot message that only narrates a step: text, no card, ask, file or computer. */
|
||||
export function isStepNarration(message: RunMessage): boolean {
|
||||
return (
|
||||
message.role === "bot" &&
|
||||
Boolean(message.runId) &&
|
||||
!message.id.includes(":") &&
|
||||
message.blocks.length > 0 &&
|
||||
message.blocks.every((block) => block.kind === "text")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a run's step narration into the message that follows it, so a long run reads as its
|
||||
* latest message with the earlier steps one tap away instead of a wall of bubbles.
|
||||
* Returns, per message id, the steps folded into it; folded messages are listed in `hidden`.
|
||||
*/
|
||||
export function foldRunNarration<Message extends RunMessage>(
|
||||
messages: readonly Message[],
|
||||
): {
|
||||
steps: Map<string, Message[]>;
|
||||
hidden: Set<string>;
|
||||
} {
|
||||
const steps = new Map<string, Message[]>();
|
||||
const hidden = new Set<string>();
|
||||
const fold = (carrier: Message, folded: Message[]) => {
|
||||
if (folded.length === 0) return;
|
||||
steps.set(carrier.id, folded);
|
||||
for (const step of folded) hidden.add(step.id);
|
||||
};
|
||||
// The run's last narration carries the ones before it.
|
||||
const flush = (pending: Message[]) => {
|
||||
const carrier = pending.at(-1);
|
||||
if (carrier) fold(carrier, pending.slice(0, -1));
|
||||
};
|
||||
let pending: Message[] = [];
|
||||
for (const message of messages) {
|
||||
// Tool-only rows are not shown, so they neither carry nor break a run's steps.
|
||||
if (message.blocks.every(isToolActivityBlock)) continue;
|
||||
const first = pending[0];
|
||||
const sameRun =
|
||||
first !== undefined &&
|
||||
message.role === "bot" &&
|
||||
message.runId === first.runId &&
|
||||
message.botId === first.botId;
|
||||
if (!sameRun) {
|
||||
flush(pending);
|
||||
pending = [];
|
||||
}
|
||||
if (isStepNarration(message)) {
|
||||
pending = [...pending, message];
|
||||
} else if (message.id.includes(":") || message.blocks.some((block) => block.kind === "text")) {
|
||||
// The live row or a written answer carries the steps before it.
|
||||
fold(message, pending);
|
||||
pending = [];
|
||||
} else {
|
||||
// A card alone never hides the bot's words, so the last step stays visible above it.
|
||||
flush(pending);
|
||||
pending = [];
|
||||
}
|
||||
}
|
||||
flush(pending);
|
||||
return { steps, hidden };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user