157 lines
5.0 KiB
TypeScript
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);
|
|
});
|
|
});
|