Files
openwork/apps/app/tests/message-list-step-fold.test.tsx

157 lines
5.0 KiB
TypeScript

/** @jsxImportSource react */
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { DynamicToolUIPart, UIMessage } from "ai";
import { MessageList } from "../src/components/chat/message-list";
import { MessageListProvider } from "../src/components/chat/message-list-provider";
function bashPart(id: string): DynamicToolUIPart {
return {
type: "dynamic-tool",
toolName: "bash",
toolCallId: id,
state: "output-available",
input: { command: `echo ${id}`, description: "run" },
output: "ok",
};
}
function editPart(id: string, filePath: string): DynamicToolUIPart {
return {
type: "dynamic-tool",
toolName: "edit",
toolCallId: id,
state: "output-available",
input: { filePath, oldString: "a", newString: "b" },
output: "ok",
};
}
/**
* Other test files stub `globalThis.window` and can leak it into a shared
* bun test worker. Static SSR rendering must not see a partial window stub
* (components probe it for addEventListener), so hide it for the render.
*/
function withoutWindow<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window");
if (descriptor?.configurable) {
Reflect.deleteProperty(globalThis, "window");
}
try {
return run();
} finally {
if (descriptor?.configurable) {
Object.defineProperty(globalThis, "window", descriptor);
}
}
}
function renderList(messages: UIMessage[]) {
return withoutWindow(() => renderToStaticMarkup(
<MessageListProvider
workspaceId="ws"
sessionId="session"
showThinking={true}
developerMode={false}
displaySuggestions={false}
providerConnectedCount={1}
dispatchAction={() => {}}
setPrompt={() => {}}
onRevertToUserMessage={() => {}}
onForkAtMessage={() => {}}
onEditUserMessage={() => {}}
onMcpReconnect={() => Promise.reject(new Error("unused"))}
onMcpReopenAuthorization={() => Promise.resolve()}
onMcpRetry={() => {}}
>
<MessageList messages={messages} status="ready" />
</MessageListProvider>
));
}
const userMessage: UIMessage = {
id: "user-1",
role: "user",
metadata: { opencode: { created: 1_000 } },
parts: [{ type: "text", text: "do the thing", state: "done" }],
};
describe("finished turn step fold (single OpenCode message per turn)", () => {
test("folds interleaved steps into a 'Worked for …' line and keeps the answer", () => {
const assistant: UIMessage = {
id: "assistant-1",
role: "assistant",
metadata: { opencode: { created: 1_000, completed: 80_000 } },
parts: [
{ type: "step-start" },
{ type: "reasoning", text: "planning the change", state: "done" },
bashPart("c1"),
editPart("c2", "/repo/src/a.ts"),
{ type: "text", text: "Now checking the result:", state: "done" },
bashPart("c3"),
bashPart("c4"),
bashPart("c5"),
{ type: "text", text: "Everything passed — the change is in.", state: "done" },
],
};
const markup = renderList([userMessage, assistant]);
// 79 seconds of work between created and completed.
expect(markup).toContain("Worked for 1m 19s");
// The answer stays visible outside the fold.
expect(markup).toContain("Everything passed — the change is in.");
});
test("a short turn stays inline with one aggregate line", () => {
const assistant: UIMessage = {
id: "assistant-2",
role: "assistant",
metadata: { opencode: { created: 1_000, completed: 5_000 } },
parts: [
{ type: "step-start" },
bashPart("c1"),
editPart("c2", "/repo/src/a.ts"),
{ type: "text", text: "Done.", state: "done" },
],
};
const markup = renderList([userMessage, assistant]);
expect(markup).not.toContain("Worked for");
// Both calls merge into one aggregate summary line.
expect(markup).toContain("Edited 1 file, ran command");
expect(markup).toContain("Done.");
});
test("reasoning between calls stays one aggregate line that advertises its thought", () => {
const assistant: UIMessage = {
id: "assistant-3",
role: "assistant",
metadata: { opencode: { created: 1_000, completed: 4_000 } },
parts: [
{ type: "step-start" },
{ type: "reasoning", text: "first", state: "done" },
bashPart("c1"),
{ type: "reasoning", text: "second", state: "done" },
bashPart("c2"),
{ type: "text", text: "Done.", state: "done" },
],
};
const markup = renderList([userMessage, assistant]);
// No thought/command ladder: the run is ONE aggregate line…
expect(markup).toContain("Ran 2 commands");
// …that counts the thought it carries.
expect(markup).toContain("1 thought");
// The turn-opening thought still renders as its own line above the run.
const openingThought = markup.indexOf("Thought");
const run = markup.indexOf("Ran 2 commands");
expect(openingThought).toBeGreaterThan(-1);
expect(run).toBeGreaterThan(openingThought);
});
});