Show OpenCode tool activity consistently (#1171)
* fix(server): map OpenCode tool details * test(server): update OpenCode history client stub
This commit is contained in:
@@ -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 () => {
|
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
|
||||||
// Yield the server-connected event, then park forever. The adapter waits
|
// Yield the server-connected event, then park forever. The adapter waits
|
||||||
// for that first event before sending the prompt.
|
// for that first event before sending the prompt.
|
||||||
|
|||||||
@@ -884,6 +884,17 @@ function buildOpenCodeReplayPartTimelineEvent(params: {
|
|||||||
if (part.type !== "tool") {
|
if (part.type !== "tool") {
|
||||||
return null;
|
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);
|
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
|
||||||
if (!parsedToolPart.success || !parsedToolPart.data) {
|
if (!parsedToolPart.success || !parsedToolPart.data) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1537,6 +1548,56 @@ function readNonEmptyString(value: unknown): string | null {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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(
|
function mapOpenCodeTodosToTimelineItems(
|
||||||
todos: Array<{ content?: string | null; status?: string | null }>,
|
todos: Array<{ content?: string | null; status?: string | null }>,
|
||||||
): Extract<AgentTimelineItem, { type: "todo" }> {
|
): Extract<AgentTimelineItem, { type: "todo" }> {
|
||||||
@@ -2093,6 +2154,9 @@ function appendOpenCodeMessagePartUpdated(
|
|||||||
events: AgentStreamEvent[],
|
events: AgentStreamEvent[],
|
||||||
): void {
|
): void {
|
||||||
const part = event.properties.part;
|
const part = event.properties.part;
|
||||||
|
if (part.type === "tool" && isOpenCodeTodoWriteToolPart(part)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (part.sessionID !== state.sessionId) {
|
if (part.sessionID !== state.sessionId) {
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
appendOpenCodeSubAgentChildToolPart(part, state, events);
|
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", () => {
|
it("emits compaction loading timeline items from compaction parts", () => {
|
||||||
const state = createState();
|
const state = createState();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
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 {
|
import {
|
||||||
ToolEditInputSchema,
|
ToolEditInputSchema,
|
||||||
ToolEditOutputSchema,
|
ToolEditOutputSchema,
|
||||||
|
ToolGrepOutputSchema,
|
||||||
ToolGlobOutputSchema,
|
ToolGlobOutputSchema,
|
||||||
ToolReadInputSchema,
|
ToolReadInputSchema,
|
||||||
ToolReadOutputSchema,
|
ToolReadOutputSchema,
|
||||||
@@ -49,6 +50,109 @@ function readOutputText(value: unknown): string | undefined {
|
|||||||
return 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 {
|
function formatLogEntry(value: unknown): string | undefined {
|
||||||
const outputText = readOutputText(value);
|
const outputText = readOutputText(value);
|
||||||
if (outputText) {
|
if (outputText) {
|
||||||
@@ -167,6 +271,12 @@ const OpencodeKnownToolDetailSchema = z.union([
|
|||||||
ToolEditOutputSchema,
|
ToolEditOutputSchema,
|
||||||
toEditToolDetail,
|
toEditToolDetail,
|
||||||
),
|
),
|
||||||
|
toolDetailBranchByToolName(
|
||||||
|
"apply_patch",
|
||||||
|
OpencodeApplyPatchTextInputSchema,
|
||||||
|
z.unknown(),
|
||||||
|
(input) => toEditToolDetail(input, null),
|
||||||
|
),
|
||||||
toolDetailBranchByToolName(
|
toolDetailBranchByToolName(
|
||||||
"apply_diff",
|
"apply_diff",
|
||||||
ToolEditInputSchema,
|
ToolEditInputSchema,
|
||||||
@@ -176,6 +286,12 @@ const OpencodeKnownToolDetailSchema = z.union([
|
|||||||
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||||
toSearchToolDetail({ input, toolName: "search" }),
|
toSearchToolDetail({ input, toolName: "search" }),
|
||||||
),
|
),
|
||||||
|
toolDetailBranchByToolName(
|
||||||
|
"grep",
|
||||||
|
ToolSearchInputSchema,
|
||||||
|
OpencodeGrepOutputSchema,
|
||||||
|
(input, output) => toSearchToolDetail({ input, output, toolName: "grep" }),
|
||||||
|
),
|
||||||
toolDetailBranchByToolName("glob", ToolSearchInputSchema, z.unknown(), (input, output) => {
|
toolDetailBranchByToolName("glob", ToolSearchInputSchema, z.unknown(), (input, output) => {
|
||||||
const parsedOutput = ToolGlobOutputSchema.safeParse(output);
|
const parsedOutput = ToolGlobOutputSchema.safeParse(output);
|
||||||
return toSearchToolDetail({
|
return toSearchToolDetail({
|
||||||
@@ -187,6 +303,31 @@ const OpencodeKnownToolDetailSchema = z.union([
|
|||||||
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||||
toSearchToolDetail({ input, toolName: "web_search" }),
|
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(
|
export function deriveOpencodeToolDetail(
|
||||||
|
|||||||
@@ -103,6 +103,21 @@ describe("opencode tool-call mapper", () => {
|
|||||||
query: "**/*.md",
|
query: "**/*.md",
|
||||||
toolName: "glob",
|
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", () => {
|
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", () => {
|
it("maps unknown tools to unknown detail with raw payloads", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapOpencodeToolCall({
|
mapOpencodeToolCall({
|
||||||
|
|||||||
Reference in New Issue
Block a user