|
|
@@ -1,7 +1,7 @@
|
|
|
import { describe, expect, test } from "bun:test"
|
|
|
import { ACP } from "../../src/acp/agent"
|
|
|
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
|
|
-import type { Event } from "@opencode-ai/sdk/v2"
|
|
|
+import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2"
|
|
|
import { Instance } from "../../src/project/instance"
|
|
|
import { tmpdir } from "../fixture/fixture"
|
|
|
|
|
|
@@ -19,6 +19,61 @@ type EventController = {
|
|
|
close: () => void
|
|
|
}
|
|
|
|
|
|
+function inProgressText(update: SessionUpdateParams["update"]) {
|
|
|
+ if (update.sessionUpdate !== "tool_call_update") return undefined
|
|
|
+ if (update.status !== "in_progress") return undefined
|
|
|
+ if (!update.content || !Array.isArray(update.content)) return undefined
|
|
|
+ const first = update.content[0]
|
|
|
+ if (!first || first.type !== "content") return undefined
|
|
|
+ if (first.content.type !== "text") return undefined
|
|
|
+ return first.content.text
|
|
|
+}
|
|
|
+
|
|
|
+function isToolCallUpdate(
|
|
|
+ update: SessionUpdateParams["update"],
|
|
|
+): update is Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call_update" }> {
|
|
|
+ return update.sessionUpdate === "tool_call_update"
|
|
|
+}
|
|
|
+
|
|
|
+function toolEvent(
|
|
|
+ sessionId: string,
|
|
|
+ cwd: string,
|
|
|
+ opts: {
|
|
|
+ callID: string
|
|
|
+ tool: string
|
|
|
+ input: Record<string, unknown>
|
|
|
+ } & ({ status: "running"; metadata?: Record<string, unknown> } | { status: "pending"; raw: string }),
|
|
|
+): GlobalEventEnvelope {
|
|
|
+ const state: ToolStatePending | ToolStateRunning =
|
|
|
+ opts.status === "running"
|
|
|
+ ? {
|
|
|
+ status: "running",
|
|
|
+ input: opts.input,
|
|
|
+ ...(opts.metadata && { metadata: opts.metadata }),
|
|
|
+ time: { start: Date.now() },
|
|
|
+ }
|
|
|
+ : {
|
|
|
+ status: "pending",
|
|
|
+ input: opts.input,
|
|
|
+ raw: opts.raw,
|
|
|
+ }
|
|
|
+ const payload: EventMessagePartUpdated = {
|
|
|
+ type: "message.part.updated",
|
|
|
+ properties: {
|
|
|
+ part: {
|
|
|
+ id: `part_${opts.callID}`,
|
|
|
+ sessionID: sessionId,
|
|
|
+ messageID: `msg_${opts.callID}`,
|
|
|
+ type: "tool",
|
|
|
+ callID: opts.callID,
|
|
|
+ tool: opts.tool,
|
|
|
+ state,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+ return { directory: cwd, payload }
|
|
|
+}
|
|
|
+
|
|
|
function createEventStream() {
|
|
|
const queue: GlobalEventEnvelope[] = []
|
|
|
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
|
|
|
@@ -65,6 +120,7 @@ function createEventStream() {
|
|
|
function createFakeAgent() {
|
|
|
const updates = new Map<string, string[]>()
|
|
|
const chunks = new Map<string, string>()
|
|
|
+ const sessionUpdates: SessionUpdateParams[] = []
|
|
|
const record = (sessionId: string, type: string) => {
|
|
|
const list = updates.get(sessionId) ?? []
|
|
|
list.push(type)
|
|
|
@@ -73,6 +129,7 @@ function createFakeAgent() {
|
|
|
|
|
|
const connection = {
|
|
|
async sessionUpdate(params: SessionUpdateParams) {
|
|
|
+ sessionUpdates.push(params)
|
|
|
const update = params.update
|
|
|
const type = update?.sessionUpdate ?? "unknown"
|
|
|
record(params.sessionId, type)
|
|
|
@@ -197,7 +254,7 @@ function createFakeAgent() {
|
|
|
;(agent as any).eventAbort.abort()
|
|
|
}
|
|
|
|
|
|
- return { agent, controller, calls, updates, chunks, stop, sdk, connection }
|
|
|
+ return { agent, controller, calls, updates, chunks, sessionUpdates, stop, sdk, connection }
|
|
|
}
|
|
|
|
|
|
describe("acp.agent event subscription", () => {
|
|
|
@@ -435,4 +492,101 @@ describe("acp.agent event subscription", () => {
|
|
|
},
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+ test("streams running bash output snapshots and de-dupes identical snapshots", async () => {
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
|
|
+ const cwd = "/tmp/opencode-acp-test"
|
|
|
+ const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
|
|
+ const input = { command: "echo hello", description: "run command" }
|
|
|
+
|
|
|
+ for (const output of ["a", "a", "ab"]) {
|
|
|
+ controller.push(
|
|
|
+ toolEvent(sessionId, cwd, { callID: "call_1", tool: "bash", status: "running", input, metadata: { output } }),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ await new Promise((r) => setTimeout(r, 20))
|
|
|
+
|
|
|
+ const snapshots = sessionUpdates
|
|
|
+ .filter((u) => u.sessionId === sessionId)
|
|
|
+ .filter((u) => isToolCallUpdate(u.update))
|
|
|
+ .map((u) => inProgressText(u.update))
|
|
|
+
|
|
|
+ expect(snapshots).toEqual(["a", undefined, "ab"])
|
|
|
+ stop()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("emits synthetic pending before first running update for any tool", async () => {
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
|
|
+ const cwd = "/tmp/opencode-acp-test"
|
|
|
+ const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
|
|
+
|
|
|
+ controller.push(
|
|
|
+ toolEvent(sessionId, cwd, {
|
|
|
+ callID: "call_bash",
|
|
|
+ tool: "bash",
|
|
|
+ status: "running",
|
|
|
+ input: { command: "echo hi", description: "run command" },
|
|
|
+ metadata: { output: "hi\n" },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ controller.push(
|
|
|
+ toolEvent(sessionId, cwd, {
|
|
|
+ callID: "call_read",
|
|
|
+ tool: "read",
|
|
|
+ status: "running",
|
|
|
+ input: { filePath: "/tmp/example.txt" },
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ await new Promise((r) => setTimeout(r, 20))
|
|
|
+
|
|
|
+ const types = sessionUpdates
|
|
|
+ .filter((u) => u.sessionId === sessionId)
|
|
|
+ .map((u) => u.update.sessionUpdate)
|
|
|
+ .filter((u) => u === "tool_call" || u === "tool_call_update")
|
|
|
+ expect(types).toEqual(["tool_call", "tool_call_update", "tool_call", "tool_call_update"])
|
|
|
+
|
|
|
+ const pendings = sessionUpdates.filter(
|
|
|
+ (u) => u.sessionId === sessionId && u.update.sessionUpdate === "tool_call",
|
|
|
+ )
|
|
|
+ expect(pendings.every((p) => p.update.sessionUpdate === "tool_call" && p.update.status === "pending")).toBe(true)
|
|
|
+ stop()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ test("clears bash snapshot marker on pending state", async () => {
|
|
|
+ await using tmp = await tmpdir()
|
|
|
+ await Instance.provide({
|
|
|
+ directory: tmp.path,
|
|
|
+ fn: async () => {
|
|
|
+ const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
|
|
+ const cwd = "/tmp/opencode-acp-test"
|
|
|
+ const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
|
|
+ const input = { command: "echo hello", description: "run command" }
|
|
|
+
|
|
|
+ controller.push(toolEvent(sessionId, cwd, { callID: "call_1", tool: "bash", status: "running", input, metadata: { output: "a" } }))
|
|
|
+ controller.push(toolEvent(sessionId, cwd, { callID: "call_1", tool: "bash", status: "pending", input, raw: '{"command":"echo hello"}' }))
|
|
|
+ controller.push(toolEvent(sessionId, cwd, { callID: "call_1", tool: "bash", status: "running", input, metadata: { output: "a" } }))
|
|
|
+ await new Promise((r) => setTimeout(r, 20))
|
|
|
+
|
|
|
+ const snapshots = sessionUpdates
|
|
|
+ .filter((u) => u.sessionId === sessionId)
|
|
|
+ .filter((u) => isToolCallUpdate(u.update))
|
|
|
+ .map((u) => inProgressText(u.update))
|
|
|
+
|
|
|
+ expect(snapshots).toEqual(["a", "a"])
|
|
|
+ stop()
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
})
|