Show OpenCode tool activity consistently (#1171)

* fix(server): map OpenCode tool details

* test(server): update OpenCode history client stub
This commit is contained in:
Mohamed Boudra
2026-05-25 18:04:33 +08:00
committed by GitHub
parent 05eec04b10
commit 178708a44e
5 changed files with 591 additions and 1 deletions
@@ -1082,6 +1082,171 @@ describe("OpenCode adapter startTurn error handling", () => {
]);
});
test("streamHistory maps persisted OpenCode tool parts through canonical detail branches", async () => {
const patchText = [
"*** Begin Patch",
"*** Delete File: /tmp/repo/src/App.tsx",
"*** End Patch",
].join("\n");
const fakeClient = {
session: {
get: vi.fn().mockResolvedValue({
data: { revert: undefined },
error: undefined,
}),
messages: vi.fn().mockResolvedValue({
data: [
{
info: {
id: "msg_assistant",
sessionID: "ses_unit_test",
role: "assistant",
},
parts: [
{
id: "part-grep",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "tool",
tool: "grep",
callID: "call-grep",
state: {
status: "completed",
input: { pattern: "sendCorrelatedSessionRequest" },
},
},
{
id: "part-skill",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "tool",
tool: "skill",
callID: "call-skill",
state: {
status: "completed",
input: { name: "diagnose" },
output: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
},
},
{
id: "part-apply-patch",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "tool",
tool: "apply_patch",
callID: "call-apply-patch",
state: {
status: "completed",
input: { patchText },
output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx",
},
},
{
id: "part-todowrite",
sessionID: "ses_unit_test",
messageID: "msg_assistant",
type: "tool",
tool: "todowrite",
callID: "call-todowrite",
state: {
status: "completed",
input: {
todos: [
{
content: "Inspect current directory and existing files",
status: "completed",
priority: "high",
},
],
},
},
},
],
},
],
error: undefined,
}),
},
} as never;
const session = new __openCodeInternals.OpenCodeAgentSession(
{ provider: "opencode", cwd: "/tmp/repo" },
fakeClient,
"ses_unit_test",
createTestLogger(),
);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(history).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
callId: "call-grep",
name: "grep",
status: "completed",
detail: {
type: "search",
query: "sendCorrelatedSessionRequest",
toolName: "grep",
},
error: null,
},
},
{
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
callId: "call-skill",
name: "skill",
status: "completed",
detail: {
type: "plain_text",
label: "diagnose",
icon: "sparkles",
text: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
},
error: null,
},
},
{
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
callId: "call-apply-patch",
name: "apply_patch",
status: "completed",
detail: {
type: "edit",
filePath: "/tmp/repo/src/App.tsx",
unifiedDiff: [
"diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx",
"--- a//tmp/repo/src/App.tsx",
"+++ /dev/null",
].join("\n"),
},
error: null,
},
},
{
type: "timeline",
provider: "opencode",
item: {
type: "todo",
items: [{ text: "Inspect current directory and existing files", completed: true }],
},
},
]);
});
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
// Yield the server-connected event, then park forever. The adapter waits
// for that first event before sending the prompt.
@@ -884,6 +884,17 @@ function buildOpenCodeReplayPartTimelineEvent(params: {
if (part.type !== "tool") {
return null;
}
if (isOpenCodeTodoWriteToolPart(part)) {
const todos = readOpenCodeTodoItemsFromToolPart(part);
if (!todos) {
return null;
}
return buildOpenCodeReplayTimelineEvent({
item: mapOpenCodeTodosToTimelineItems(todos),
message,
part,
});
}
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (!parsedToolPart.success || !parsedToolPart.data) {
return null;
@@ -1537,6 +1548,56 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function isOpenCodeTodoWriteToolPart(part: OpenCodeToolPartEventPart | OpenCodePart): boolean {
return part.type === "tool" && part.tool.trim().toLowerCase() === "todowrite";
}
function readOpenCodeTodoItems(
value: unknown,
): Array<{ content?: string | null; status?: string | null }> | null {
if (typeof value === "string") {
try {
return readOpenCodeTodoItems(JSON.parse(value));
} catch {
return null;
}
}
if (Array.isArray(value)) {
return value.flatMap((entry) => {
const record = readOpenCodeRecord(entry);
if (!record) {
return [];
}
const content = readNonEmptyString(record.content);
if (!content) {
return [];
}
return [
{
content,
status: readNonEmptyString(record.status),
},
];
});
}
const record = readOpenCodeRecord(value);
if (!record) {
return null;
}
return readOpenCodeTodoItems(record.todos);
}
function readOpenCodeTodoItemsFromToolPart(
part: Extract<OpenCodePart, { type: "tool" }>,
): Array<{ content?: string | null; status?: string | null }> | null {
const state = readOpenCodeRecord(part.state);
return (
readOpenCodeTodoItems(state?.input) ??
readOpenCodeTodoItems(state?.output) ??
readOpenCodeTodoItems(state?.metadata)
);
}
function mapOpenCodeTodosToTimelineItems(
todos: Array<{ content?: string | null; status?: string | null }>,
): Extract<AgentTimelineItem, { type: "todo" }> {
@@ -2093,6 +2154,9 @@ function appendOpenCodeMessagePartUpdated(
events: AgentStreamEvent[],
): void {
const part = event.properties.part;
if (part.type === "tool" && isOpenCodeTodoWriteToolPart(part)) {
return;
}
if (part.sessionID !== state.sessionId) {
if (part.type === "tool") {
appendOpenCodeSubAgentChildToolPart(part, state, events);
@@ -541,6 +541,143 @@ describe("translateOpenCodeEvent", () => {
]);
});
it("suppresses live todowrite tool parts because OpenCode emits todo.updated separately", () => {
const state = createState();
const events = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "part-todowrite",
sessionID: "session-1",
messageID: "message-1",
type: "tool",
tool: "todowrite",
callID: "call-todowrite",
state: {
status: "running",
input: {},
},
},
},
},
state,
);
expect(events).toEqual([]);
});
it("maps live OpenCode tool parts through canonical detail branches", () => {
const state = createState();
const patchText = [
"*** Begin Patch",
"*** Delete File: /tmp/repo/src/App.tsx",
"*** End Patch",
].join("\n");
const events = [
{
id: "part-grep",
tool: "grep",
callID: "call-grep",
state: { status: "completed", input: { pattern: "sendCorrelatedSessionRequest" } },
},
{
id: "part-skill",
tool: "skill",
callID: "call-skill",
state: {
status: "completed",
input: { name: "diagnose" },
output: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
},
},
{
id: "part-apply-patch",
tool: "apply_patch",
callID: "call-apply-patch",
state: {
status: "completed",
input: { patchText },
output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx",
},
},
].flatMap((part) =>
translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
...part,
sessionID: "session-1",
messageID: "message-1",
type: "tool",
},
},
},
state,
),
);
expect(events).toEqual([
{
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
callId: "call-grep",
name: "grep",
status: "completed",
detail: {
type: "search",
query: "sendCorrelatedSessionRequest",
toolName: "grep",
},
error: null,
},
},
{
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
callId: "call-skill",
name: "skill",
status: "completed",
detail: {
type: "plain_text",
label: "diagnose",
icon: "sparkles",
text: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
},
error: null,
},
},
{
type: "timeline",
provider: "opencode",
item: {
type: "tool_call",
callId: "call-apply-patch",
name: "apply_patch",
status: "completed",
detail: {
type: "edit",
filePath: "/tmp/repo/src/App.tsx",
unifiedDiff: [
"diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx",
"--- a//tmp/repo/src/App.tsx",
"+++ /dev/null",
].join("\n"),
},
error: null,
},
},
]);
});
it("emits compaction loading timeline items from compaction parts", () => {
const state = createState();
@@ -1,10 +1,11 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import { nonEmptyString } from "../tool-call-mapper-utils.js";
import { nonEmptyString, truncateDiffText } from "../tool-call-mapper-utils.js";
import {
ToolEditInputSchema,
ToolEditOutputSchema,
ToolGrepOutputSchema,
ToolGlobOutputSchema,
ToolReadInputSchema,
ToolReadOutputSchema,
@@ -49,6 +50,109 @@ function readOutputText(value: unknown): string | undefined {
return undefined;
}
function parseApplyPatchDirective(
line: string,
): { kind: "add" | "update" | "delete"; path: string } | null {
const trimmed = line.trim();
if (trimmed.startsWith("*** Add File:")) {
return { kind: "add", path: trimmed.replace("*** Add File:", "").trim() };
}
if (trimmed.startsWith("*** Update File:")) {
return { kind: "update", path: trimmed.replace("*** Update File:", "").trim() };
}
if (trimmed.startsWith("*** Delete File:")) {
return { kind: "delete", path: trimmed.replace("*** Delete File:", "").trim() };
}
return null;
}
function extractApplyPatchPrimaryFilePath(patchText: string): string | undefined {
for (const line of patchText.split(/\r?\n/)) {
const directive = parseApplyPatchDirective(line);
if (directive?.path) {
return directive.path;
}
}
return undefined;
}
function normalizeDiffHeaderPath(rawPath: string): string {
return rawPath.trim().replace(/^["']+|["']+$/g, "");
}
function applyPatchToUnifiedDiff(patchText: string): string {
const lines = patchText.replace(/\r\n/g, "\n").split("\n");
const output: string[] = [];
let sawDiffContent = false;
for (const line of lines) {
const directive = parseApplyPatchDirective(line);
if (directive) {
const filePath = normalizeDiffHeaderPath(directive.path);
if (filePath.length > 0) {
if (output.length > 0 && output[output.length - 1] !== "") {
output.push("");
}
const left = directive.kind === "add" ? "/dev/null" : `a/${filePath}`;
const right = directive.kind === "delete" ? "/dev/null" : `b/${filePath}`;
output.push(`diff --git a/${filePath} b/${filePath}`);
output.push(`--- ${left}`);
output.push(`+++ ${right}`);
sawDiffContent = true;
}
continue;
}
const trimmed = line.trim();
if (
trimmed === "*** Begin Patch" ||
trimmed === "*** End Patch" ||
trimmed === "*** End of File" ||
trimmed.startsWith("*** Move to:")
) {
continue;
}
if (
line.startsWith("@@") ||
line.startsWith("+") ||
line.startsWith("-") ||
line.startsWith(" ") ||
line.startsWith("\\ No newline at end of file")
) {
output.push(line);
sawDiffContent = true;
}
}
if (!sawDiffContent) {
return patchText;
}
const normalized = output.join("\n").trim();
return normalized.length > 0 ? normalized : patchText;
}
const OpencodeApplyPatchTextInputSchema = z
.object({ patchText: z.string() })
.passthrough()
.transform((value) => {
const filePath = extractApplyPatchPrimaryFilePath(value.patchText);
return {
filePath: filePath ?? "",
oldString: undefined,
newString: undefined,
unifiedDiff: truncateDiffText(applyPatchToUnifiedDiff(value.patchText)),
};
});
const OpencodeGrepOutputSchema = z
.union([
ToolGrepOutputSchema,
z.string().transform((output) => ({ numFiles: 0, filenames: [], content: output })),
])
.nullable();
function formatLogEntry(value: unknown): string | undefined {
const outputText = readOutputText(value);
if (outputText) {
@@ -167,6 +271,12 @@ const OpencodeKnownToolDetailSchema = z.union([
ToolEditOutputSchema,
toEditToolDetail,
),
toolDetailBranchByToolName(
"apply_patch",
OpencodeApplyPatchTextInputSchema,
z.unknown(),
(input) => toEditToolDetail(input, null),
),
toolDetailBranchByToolName(
"apply_diff",
ToolEditInputSchema,
@@ -176,6 +286,12 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "search" }),
),
toolDetailBranchByToolName(
"grep",
ToolSearchInputSchema,
OpencodeGrepOutputSchema,
(input, output) => toSearchToolDetail({ input, output, toolName: "grep" }),
),
toolDetailBranchByToolName("glob", ToolSearchInputSchema, z.unknown(), (input, output) => {
const parsedOutput = ToolGlobOutputSchema.safeParse(output);
return toSearchToolDetail({
@@ -187,6 +303,31 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail({ input, toolName: "web_search" }),
),
toolDetailBranchByToolName(
"skill",
z.object({ name: z.string() }).passthrough(),
z
.union([
z
.object({ output: z.string() })
.passthrough()
.transform((value) => value.output),
z.string(),
])
.nullable(),
(input, output) => {
const skillName = input?.name.trim();
if (!skillName) {
return undefined;
}
return {
type: "plain_text" as const,
label: skillName,
icon: "sparkles" as const,
...(output ? { text: output } : {}),
} satisfies ToolCallDetail;
},
),
]);
export function deriveOpencodeToolDetail(
@@ -103,6 +103,21 @@ describe("opencode tool-call mapper", () => {
query: "**/*.md",
toolName: "glob",
});
const grepItem = expectMapped(
mapOpencodeToolCall({
toolName: "grep",
callId: "opencode-running-grep",
status: "running",
input: { pattern: "sendCorrelatedSessionRequest" },
output: null,
}),
);
expect(grepItem.detail).toEqual({
type: "search",
query: "sendCorrelatedSessionRequest",
toolName: "grep",
});
});
it("maps completed read calls", () => {
@@ -230,6 +245,74 @@ describe("opencode tool-call mapper", () => {
});
});
it("maps skill calls to plain text detail with the loaded skill name", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "skill",
callId: "opencode-skill-1",
status: "completed",
input: { name: "diagnose" },
output: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
}),
);
expect(item.detail).toEqual({
type: "plain_text",
label: "diagnose",
icon: "sparkles",
text: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
});
});
it("maps completed grep calls with string output into search detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "grep",
callId: "opencode-grep-string-output-1",
status: "completed",
input: { pattern: "todowrite" },
output: "Found 2 matches\nsrc/file.ts:\n Line 1: todowrite",
}),
);
expect(item.detail).toEqual({
type: "search",
query: "todowrite",
toolName: "grep",
content: "Found 2 matches\nsrc/file.ts:\n Line 1: todowrite",
numFiles: 0,
});
});
it("maps apply_patch patchText payloads into edit detail", () => {
const patchText = [
"*** Begin Patch",
"*** Delete File: /tmp/repo/src/App.tsx",
"*** End Patch",
].join("\n");
const item = expectMapped(
mapOpencodeToolCall({
toolName: "apply_patch",
callId: "opencode-apply-patch-text-1",
status: "completed",
input: { patchText },
output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx",
}),
);
expect(item.detail.type).toBe("edit");
expect(item.detail).toEqual({
type: "edit",
filePath: "/tmp/repo/src/App.tsx",
unifiedDiff: [
"diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx",
"--- a//tmp/repo/src/App.tsx",
"+++ /dev/null",
].join("\n"),
});
});
it("maps unknown tools to unknown detail with raw payloads", () => {
const item = expectMapped(
mapOpencodeToolCall({