diff --git a/packages/client/examples/agents-and-providers.ts b/packages/client/examples/agents-and-providers.ts index c27ca1871e..e1ebfe021c 100644 --- a/packages/client/examples/agents-and-providers.ts +++ b/packages/client/examples/agents-and-providers.ts @@ -58,10 +58,23 @@ export async function runFollowUp(url: string, agentId: string): Promise { + const client = createClient(url); + + try { + await client.connect(); + await client.agents.ref(agentId).send(prompt, { activeTurnBehavior: "steer" }); + } finally { + await client.close(); + } +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 23664188b7..2fe706c8ae 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -425,6 +425,7 @@ export interface DaemonClientTrace { export interface SendMessageOptions { messageId?: string; + /** What happens when the agent is mid-turn. The daemon interrupts the turn when omitted. */ activeTurnBehavior?: ActiveTurnBehavior; images?: Array<{ data: string; mimeType: string }>; attachments?: SendAgentMessageRequest["attachments"]; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 9aed42febc..0f60e52673 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -744,13 +744,17 @@ test("agent handles delegate create, send, timeline refetch, archive, and local expect(updatedAgents).toEqual(["Updated"]); expect(agent.current()).toEqual(updatedAgent); - const sendPromise = agent.send("hello", { messageId: "message-sdk" }); + const sendPromise = agent.send("hello", { + messageId: "message-sdk", + activeTurnBehavior: "steer", + }); const sendRequest = parseSentSessionMessage(ws.sent.at(-1)); expect(sendRequest).toMatchObject({ type: "send_agent_message_request", agentId: "agent_sdk", text: "hello", messageId: "message-sdk", + activeTurnBehavior: "steer", }); ws.message( @@ -768,6 +772,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local const runPromise = agent.run("finish the task", { messageId: "run-message-sdk", + activeTurnBehavior: "interrupt", timeoutMs: 30_000, }); const runSendRequest = parseSentSessionMessage(ws.sent.at(-1)); @@ -776,6 +781,7 @@ test("agent handles delegate create, send, timeline refetch, archive, and local agentId: "agent_sdk", text: "finish the task", messageId: "run-message-sdk", + activeTurnBehavior: "interrupt", }); ws.message( sessionMessage({ diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e113d48667..ae868c5974 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -24,7 +24,6 @@ import type { ProjectPlacementPayload, WorkspaceProjectDescriptorPayload, RefreshProvidersSnapshotResponseMessage, - SendAgentMessageRequest, SessionOutboundMessage, WorkspaceDescriptorPayload, WorkspaceCreateRequest, @@ -55,6 +54,7 @@ import type { FetchAgentTimelineDirection, FetchAgentTimelinePayload, FetchAgentTimelineProjection, + SendMessageOptions, WaitForFinishResult, } from "./daemon-client.js"; @@ -276,11 +276,7 @@ export interface PaseoAgentTimelineRefetchOptions { requestId?: string; } -export interface PaseoAgentSendOptions { - messageId?: string; - images?: Array<{ data: string; mimeType: string }>; - attachments?: SendAgentMessageRequest["attachments"]; -} +export type PaseoAgentSendOptions = SendMessageOptions; export interface PaseoAgentRunOptions extends PaseoAgentSendOptions { timeoutMs?: number; diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index afe89b85b4..39a7deb8c9 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -221,6 +221,15 @@ function deriveOpencodeTaskDetail( }; } +function toOpencodeEditInput(value: { filePath: string; oldString?: string; newString?: string }) { + return { + filePath: value.filePath, + oldString: nonEmptyString(value.oldString), + newString: nonEmptyString(value.newString), + unifiedDiff: undefined, + }; +} + const OpencodeEditInputSchema = z.union([ z .object({ @@ -229,12 +238,12 @@ const OpencodeEditInputSchema = z.union([ newString: z.string().optional(), }) .passthrough() - .transform((value) => ({ - filePath: value.filePath, - oldString: nonEmptyString(value.oldString), - newString: nonEmptyString(value.newString), - unifiedDiff: undefined, - })), + .transform(toOpencodeEditInput), + // OpenCode v2's edit tool names the file `path`. + z + .object({ path: z.string(), oldString: z.string(), newString: z.string() }) + .passthrough() + .transform((value) => toOpencodeEditInput({ ...value, filePath: value.path })), ToolEditInputSchema, ]); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts new file mode 100644 index 0000000000..1bbd86490a --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -0,0 +1,56 @@ +import type { SessionMessageAssistant } from "@opencode/client"; +import { expect, test } from "vitest"; +import { V2Timeline } from "./timeline.js"; + +function assistant(content: SessionMessageAssistant["content"]): SessionMessageAssistant { + return { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content, + }; +} + +test("a completed edit carries the replaced and replacement text", () => { + // Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`. + const edit = assistant([ + { + type: "tool", + id: "call-edit", + name: "edit", + executed: false, + state: { + status: "completed", + input: { path: "a.txt", oldString: "beta", newString: "BETA" }, + content: [{ type: "text", text: "Edited a.txt (1 replacement)" }], + metadata: { + files: [ + { + file: "a.txt", + patch: + "Index: a.txt\n===================================================================\n--- a.txt\n+++ a.txt\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n", + status: "modified", + additions: 1, + deletions: 1, + }, + ], + truncated: false, + }, + }, + time: { created: 2, ran: 2, completed: 2 }, + }, + ]); + + expect(new V2Timeline().messages([edit])).toMatchObject([ + { + item: { + type: "tool_call", + name: "edit", + status: "completed", + detail: { type: "edit", filePath: "a.txt", oldString: "beta", newString: "BETA" }, + }, + }, + ]); +});