源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
/** @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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user