356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test";
|
|
import type { Part, Session } from "@opencode-ai/sdk/v2/client";
|
|
import type { UIMessage } from "ai";
|
|
|
|
import { getReactQueryClient } from "../src/react-app/infra/query-client";
|
|
import {
|
|
__applySessionSyncEventForTest,
|
|
__createWorkspaceSessionSyncForTest,
|
|
trackWorkspaceSessionSync,
|
|
transcriptKey,
|
|
} from "../src/react-app/domains/session/sync/session-sync";
|
|
import {
|
|
parseDynamicToolUIPart,
|
|
parseStructuredOutputUIPart,
|
|
} from "../src/react-app/domains/session/sync/parse-tool-parts";
|
|
import { parseOpenWorkSessionCreateResult } from "../src/components/tools/openwork-session-create";
|
|
|
|
afterEach(() => {
|
|
getReactQueryClient().clear();
|
|
});
|
|
|
|
function writeToolPart(
|
|
status: "pending" | "running" | "completed" | "error",
|
|
input: Record<string, unknown>,
|
|
overrides: Partial<Extract<Part, { type: "tool" }>> = {},
|
|
error = "failed",
|
|
): Extract<Part, { type: "tool" }> {
|
|
const base = {
|
|
id: "part-write",
|
|
sessionID: "session-a",
|
|
messageID: "msg-a",
|
|
type: "tool" as const,
|
|
callID: "call-write",
|
|
tool: "write",
|
|
};
|
|
|
|
if (status === "completed") {
|
|
return {
|
|
...base,
|
|
...overrides,
|
|
state: {
|
|
status: "completed",
|
|
input,
|
|
output: "ok",
|
|
title: "Write",
|
|
metadata: {},
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
};
|
|
}
|
|
|
|
if (status === "error") {
|
|
return {
|
|
...base,
|
|
...overrides,
|
|
state: {
|
|
status: "error",
|
|
input,
|
|
error,
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
};
|
|
}
|
|
|
|
if (status === "running") {
|
|
return {
|
|
...base,
|
|
...overrides,
|
|
state: {
|
|
status: "running",
|
|
input,
|
|
time: { start: 1 },
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
...base,
|
|
...overrides,
|
|
state: {
|
|
status: "pending",
|
|
input,
|
|
raw: "",
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("tool part mapper", () => {
|
|
test("defers in-progress tools with empty input", () => {
|
|
// shouldDeferInProgressTool left with the legacy message list (#2016);
|
|
// the deferral behavior itself is still pinned here via the parser and
|
|
// end-to-end below via session sync.
|
|
expect(parseDynamicToolUIPart(writeToolPart("pending", {}))).toBeNull();
|
|
expect(parseDynamicToolUIPart(writeToolPart("running", {}))).toBeNull();
|
|
});
|
|
|
|
test("maps in-progress tools with partial input as input-streaming", () => {
|
|
const part = writeToolPart("running", { content: "hello" });
|
|
expect(parseDynamicToolUIPart(part)).toMatchObject({
|
|
type: "dynamic-tool",
|
|
toolName: "write",
|
|
state: "input-streaming",
|
|
input: { content: "hello" },
|
|
});
|
|
});
|
|
|
|
test("maps completed tools", () => {
|
|
const part = writeToolPart("completed", { content: "hello", filePath: "src/a.ts" });
|
|
expect(parseDynamicToolUIPart(part)).toMatchObject({
|
|
state: "output-available",
|
|
input: { content: "hello", filePath: "src/a.ts" },
|
|
output: "ok",
|
|
});
|
|
});
|
|
|
|
test("preserves MCP Apps result metadata for the chat host", () => {
|
|
const part = writeToolPart("completed", { configObjectId: "script_1" });
|
|
if (part.state.status !== "completed") throw new Error("Expected completed fixture");
|
|
part.state.metadata = {
|
|
openworkMcpApp: {
|
|
content: [{ type: "text", text: "Fallback" }],
|
|
structuredContent: { schemaVersion: "1", value: 42 },
|
|
_meta: { receiptId: "receipt_1" },
|
|
},
|
|
};
|
|
|
|
expect(parseDynamicToolUIPart(part)?.callProviderMetadata).toEqual({
|
|
opencode: { partId: "part-write" },
|
|
openwork: {
|
|
mcpResult: {
|
|
content: [{ type: "text", text: "Fallback" }],
|
|
structuredContent: { schemaVersion: "1", value: 42 },
|
|
_meta: { receiptId: "receipt_1" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("forwards the task tool's sub-agent session id for chat navigation", () => {
|
|
const running = writeToolPart(
|
|
"running",
|
|
{ description: "Explore", prompt: "look around", subagent_type: "explore" },
|
|
{ id: "part-task", tool: "task", callID: "call-task" },
|
|
);
|
|
if (running.state.status !== "running") throw new Error("Expected running fixture");
|
|
running.state.metadata = { sessionId: "ses_child_1", model: { providerID: "p", modelID: "m" } };
|
|
|
|
expect(parseDynamicToolUIPart(running)?.callProviderMetadata).toEqual({
|
|
opencode: { partId: "part-task" },
|
|
openwork: { childSessionId: "ses_child_1" },
|
|
});
|
|
|
|
const completed = writeToolPart(
|
|
"completed",
|
|
{ description: "Explore", prompt: "look around", subagent_type: "explore" },
|
|
{ id: "part-task", tool: "task", callID: "call-task" },
|
|
);
|
|
if (completed.state.status !== "completed") throw new Error("Expected completed fixture");
|
|
completed.state.metadata = { sessionId: "ses_child_1" };
|
|
|
|
expect(parseDynamicToolUIPart(completed)?.callProviderMetadata).toEqual({
|
|
opencode: { partId: "part-task" },
|
|
openwork: { childSessionId: "ses_child_1" },
|
|
});
|
|
});
|
|
|
|
test("does not forward session metadata for non-task tools", () => {
|
|
const part = writeToolPart("completed", { filePath: "src/a.ts" });
|
|
if (part.state.status !== "completed") throw new Error("Expected completed fixture");
|
|
part.state.metadata = { sessionId: "ses_child_1" };
|
|
|
|
expect(parseDynamicToolUIPart(part)?.callProviderMetadata).toEqual({
|
|
opencode: { partId: "part-write" },
|
|
});
|
|
});
|
|
|
|
test("recovers a connection-action MCP App from an errored capability result", () => {
|
|
const error = JSON.stringify({
|
|
error: "needs_connection",
|
|
message: "Connect Acme Tracker.",
|
|
connectionStatus: {
|
|
connectionId: "emc_acme",
|
|
connectionName: "Acme Tracker",
|
|
state: "needs_connection",
|
|
actor: "member",
|
|
message: "Acme Tracker is not connected.",
|
|
action: {
|
|
type: "connect",
|
|
label: "Connect Acme Tracker",
|
|
surface: "openwork_your_connections",
|
|
url: "https://app.openworklabs.com/dashboard/your-connections?connectionId=emc_acme",
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(parseDynamicToolUIPart(writeToolPart("error", {}, {}, error))).toMatchObject({
|
|
state: "output-error",
|
|
callProviderMetadata: {
|
|
openwork: {
|
|
mcpResult: {
|
|
structuredContent: {
|
|
schemaVersion: "1",
|
|
connectionId: "emc_acme",
|
|
state: "needs_connection",
|
|
},
|
|
_meta: {
|
|
"openwork/mcpApp": {
|
|
toolName: "connection_action",
|
|
resourceUri: "ui://openwork/connection-action/v1/view.html",
|
|
arguments: { connectionId: "emc_acme" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("summarizes and clamps huge HTML tool errors at ingestion", () => {
|
|
const htmlError = `<!DOCTYPE html><html><head><title>502 Bad Gateway</title></head><body>${"x".repeat(1_024 * 1_024)}</body></html>`;
|
|
const parsed = parseDynamicToolUIPart(writeToolPart("error", {}, {}, htmlError));
|
|
|
|
expect(parsed?.state).toBe("output-error");
|
|
if (!parsed || parsed.state !== "output-error") throw new Error("Expected a parsed tool error");
|
|
expect(parsed.errorText).toContain("Upstream returned an HTML error page (502 Bad Gateway)");
|
|
expect(parsed.errorText.length).toBeLessThanOrEqual(4_096);
|
|
expect(parsed.errorText.toLowerCase()).not.toContain("<!doctype");
|
|
});
|
|
|
|
test("maps env var request tools for rich chat rendering", () => {
|
|
const part = writeToolPart("running", { key: "NOTION_TOKEN" }, { tool: "request_env_var" });
|
|
expect(parseDynamicToolUIPart(part)).toMatchObject({
|
|
type: "dynamic-tool",
|
|
toolName: "request_env_var",
|
|
input: { key: "NOTION_TOKEN" },
|
|
});
|
|
});
|
|
|
|
test("parses session creation output for rich chat rendering", () => {
|
|
expect(parseOpenWorkSessionCreateResult(JSON.stringify({
|
|
ok: true,
|
|
workspaceId: "workspace-a",
|
|
workspace: "Research",
|
|
created: [{
|
|
sessionId: "session-dolphins",
|
|
title: "Dolphin research",
|
|
started: true,
|
|
route: "/workspace/workspace-a/session/session-dolphins",
|
|
}],
|
|
failures: [],
|
|
}))).toEqual({
|
|
ok: true,
|
|
workspaceId: "workspace-a",
|
|
workspace: "Research",
|
|
created: [{
|
|
sessionId: "session-dolphins",
|
|
title: "Dolphin research",
|
|
started: true,
|
|
route: "/workspace/workspace-a/session/session-dolphins",
|
|
}],
|
|
failures: [],
|
|
});
|
|
});
|
|
|
|
test("skips empty structured output while streaming", () => {
|
|
const part = writeToolPart("running", {}, { tool: "StructuredOutput" });
|
|
expect(parseStructuredOutputUIPart(part)).toBeNull();
|
|
expect(Object.keys(part.state.input).length).toBe(0);
|
|
});
|
|
|
|
test("keeps completed structured output even when input is {}", () => {
|
|
const part = writeToolPart("completed", {}, { tool: "StructuredOutput" });
|
|
expect(parseStructuredOutputUIPart(part)).toMatchObject({
|
|
type: "text",
|
|
text: "{}",
|
|
state: "done",
|
|
});
|
|
});
|
|
|
|
test("session sync defers empty in-progress write tools until input arrives", () => {
|
|
const syncInput = { workspaceId: "workspace-a", baseUrl: "http://127.0.0.1:1234", openworkToken: "token" };
|
|
const cleanup = __createWorkspaceSessionSyncForTest(syncInput);
|
|
const release = trackWorkspaceSessionSync(syncInput, "session-a");
|
|
|
|
try {
|
|
__applySessionSyncEventForTest(syncInput, {
|
|
type: "message.updated",
|
|
properties: { info: { id: "msg-a", role: "assistant", sessionID: "session-a" } },
|
|
} as any);
|
|
__applySessionSyncEventForTest(syncInput, {
|
|
type: "message.part.updated",
|
|
properties: { part: writeToolPart("pending", {}) },
|
|
} as any);
|
|
|
|
let transcript = getReactQueryClient().getQueryData<UIMessage[]>(transcriptKey("workspace-a", "session-a"));
|
|
expect(transcript?.[0]?.parts ?? []).toEqual([]);
|
|
|
|
__applySessionSyncEventForTest(syncInput, {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: writeToolPart("running", { content: "hello", filePath: "src/main.ts" }),
|
|
},
|
|
} as any);
|
|
|
|
transcript = getReactQueryClient().getQueryData<UIMessage[]>(transcriptKey("workspace-a", "session-a"));
|
|
expect(transcript?.[0]?.parts[0]).toMatchObject({
|
|
type: "dynamic-tool",
|
|
toolName: "write",
|
|
state: "input-streaming",
|
|
input: { content: "hello", filePath: "src/main.ts" },
|
|
});
|
|
} finally {
|
|
release();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
test("delivers untracked session lifecycle events for sidebar synchronization", () => {
|
|
const created: Session = {
|
|
id: "session-created",
|
|
slug: "session-created",
|
|
projectID: "project-a",
|
|
directory: "/tmp/workspace-a",
|
|
title: "Created in the background",
|
|
version: "1",
|
|
time: { created: 1, updated: 1 },
|
|
};
|
|
const createdIds: string[] = [];
|
|
const deletedIds: string[] = [];
|
|
const syncInput = {
|
|
workspaceId: "workspace-a",
|
|
baseUrl: "http://127.0.0.1:1234",
|
|
openworkToken: "token",
|
|
onSessionCreated: (session: Session) => createdIds.push(session.id),
|
|
onSessionDeleted: (sessionId: string) => deletedIds.push(sessionId),
|
|
};
|
|
const cleanup = __createWorkspaceSessionSyncForTest(syncInput);
|
|
|
|
try {
|
|
__applySessionSyncEventForTest(syncInput, {
|
|
type: "session.created",
|
|
properties: { sessionID: created.id, info: created },
|
|
});
|
|
__applySessionSyncEventForTest(syncInput, {
|
|
type: "session.deleted",
|
|
properties: { sessionID: created.id, info: created },
|
|
});
|
|
|
|
expect(createdIds).toEqual([created.id]);
|
|
expect(deletedIds).toEqual([created.id]);
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|