+21

![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



James Long
Brendan Allan
Kit Langton
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Affan Ali
affanali2k3
Frank
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Jay V
Dax Raad
Aarav Sareen
OpeOginni
Luke Parker
Ben Guthrie
Dax
Filip
Max Anderson
Brendan Allan
Jack
Shoubhit Dash
Dustin Deus
starptech
Aiden Cline
usrnk1
Jay
runvip
opencode
Julian Coy
Vladimir Glafirov
8c94e9005f
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Jay V <air@live.ca> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Ben Guthrie <benjee.012@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com> Co-authored-by: Max Anderson <max.a.anderson95@gmail.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
752 lines
24 KiB
TypeScript
752 lines
24 KiB
TypeScript
import { describe, expect, it } from "bun:test"
|
|
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
|
|
import { Effect, ManagedRuntime } from "effect"
|
|
import { ACPEvent } from "@/acp/event"
|
|
import * as ACPService from "@/acp/service"
|
|
import { Directory } from "@/acp/directory"
|
|
import { ACPSession } from "@/acp/session"
|
|
|
|
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
|
type ToolSessionUpdateParams = SessionUpdateParams & {
|
|
update: Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call" | "tool_call_update" }>
|
|
}
|
|
type GlobalEventEnvelope = {
|
|
payload?: Event
|
|
}
|
|
type DeltaPartType = Extract<Part, { type: "text" | "reasoning" }>["type"]
|
|
|
|
const pollUntil = async (
|
|
check: () => boolean | Promise<boolean>,
|
|
message: string,
|
|
opts?: { timeoutMs?: number; intervalMs?: number },
|
|
) => {
|
|
const started = Date.now()
|
|
while (true) {
|
|
if (await check()) return
|
|
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
|
|
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
|
|
}
|
|
}
|
|
|
|
function makeSessionService() {
|
|
return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync(
|
|
ACPSession.Service.use((service) => Effect.succeed(service)),
|
|
)
|
|
}
|
|
|
|
function createEventStream() {
|
|
const queue: GlobalEventEnvelope[] = []
|
|
const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
|
|
const state = { closed: false }
|
|
|
|
const push = (event: GlobalEventEnvelope) => {
|
|
const waiter = waiters.shift()
|
|
if (waiter) {
|
|
waiter(event)
|
|
return
|
|
}
|
|
queue.push(event)
|
|
}
|
|
|
|
const close = () => {
|
|
state.closed = true
|
|
for (const waiter of waiters.splice(0)) {
|
|
waiter(undefined)
|
|
}
|
|
}
|
|
|
|
const stream = async function* (signal?: AbortSignal) {
|
|
while (true) {
|
|
if (signal?.aborted) return
|
|
const next = queue.shift()
|
|
if (next) {
|
|
yield next
|
|
continue
|
|
}
|
|
if (state.closed) return
|
|
const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
|
|
waiters.push(resolve)
|
|
signal?.addEventListener("abort", () => resolve(undefined), { once: true })
|
|
})
|
|
if (!value) return
|
|
yield value
|
|
}
|
|
}
|
|
|
|
return { push, close, stream }
|
|
}
|
|
|
|
function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
|
|
const updates: SessionUpdateParams[] = []
|
|
const calls = {
|
|
eventSubscribe: 0,
|
|
message: 0,
|
|
}
|
|
const events = createEventStream()
|
|
const sdk = {
|
|
global: {
|
|
event: (options?: { signal?: AbortSignal }) => {
|
|
calls.eventSubscribe++
|
|
return Promise.resolve({ stream: events.stream(options?.signal) })
|
|
},
|
|
},
|
|
session: {
|
|
message: (input: { messageID: string }) => {
|
|
calls.message++
|
|
return Promise.resolve({ data: messages[input.messageID] })
|
|
},
|
|
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
|
|
messages: () => Promise.resolve({ data: [] }),
|
|
},
|
|
} as unknown as OpencodeClient
|
|
const connection = {
|
|
sessionUpdate: (params: SessionUpdateParams) => {
|
|
updates.push(params)
|
|
return Promise.resolve()
|
|
},
|
|
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
|
const session = makeSessionService()
|
|
const subscription = new ACPEvent.Subscription({ sdk, connection, session })
|
|
|
|
return { calls, connection, events, sdk, session, subscription, updates }
|
|
}
|
|
|
|
function textDelta(sessionID: string, messageID: string, partID: string, delta: string): Event {
|
|
return {
|
|
id: `evt_${sessionID}_${messageID}_${partID}_${delta}`,
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID,
|
|
messageID,
|
|
partID,
|
|
field: "text",
|
|
delta,
|
|
},
|
|
}
|
|
}
|
|
|
|
function partUpdated(sessionID: string, messageID: string, partID: string, type: DeltaPartType): Event {
|
|
return {
|
|
id: `evt_${sessionID}_${messageID}_${partID}`,
|
|
type: "message.part.updated",
|
|
properties: {
|
|
sessionID,
|
|
time: Date.now(),
|
|
part:
|
|
type === "text"
|
|
? {
|
|
id: partID,
|
|
sessionID,
|
|
messageID,
|
|
type: "text",
|
|
text: "",
|
|
}
|
|
: {
|
|
id: partID,
|
|
sessionID,
|
|
messageID,
|
|
type: "reasoning",
|
|
text: "",
|
|
time: { start: Date.now() },
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function toolUpdated(part: ToolPart): Event {
|
|
return {
|
|
id: `evt_${part.sessionID}_${part.messageID}_${part.id}_${part.state.status}`,
|
|
type: "message.part.updated",
|
|
properties: {
|
|
sessionID: part.sessionID,
|
|
time: Date.now(),
|
|
part,
|
|
},
|
|
}
|
|
}
|
|
|
|
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
|
|
return {
|
|
info: {
|
|
id: messageID,
|
|
sessionID,
|
|
role: "assistant",
|
|
time: { created: Date.now() },
|
|
parentID: "msg_parent",
|
|
modelID: "model",
|
|
providerID: "provider",
|
|
mode: "build",
|
|
agent: "build",
|
|
path: { cwd: "/workspace", root: "/workspace" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
},
|
|
parts: [
|
|
type === "text"
|
|
? {
|
|
id: partID,
|
|
sessionID,
|
|
messageID,
|
|
type: "text",
|
|
text: "",
|
|
}
|
|
: {
|
|
id: partID,
|
|
sessionID,
|
|
messageID,
|
|
type: "reasoning",
|
|
text: "",
|
|
time: { start: Date.now() },
|
|
},
|
|
],
|
|
} satisfies SessionMessageResponse
|
|
}
|
|
|
|
function assistantToolMessage(part: ToolPart) {
|
|
return {
|
|
info: {
|
|
id: part.messageID,
|
|
sessionID: part.sessionID,
|
|
role: "assistant",
|
|
time: { created: Date.now() },
|
|
parentID: "msg_parent",
|
|
modelID: "model",
|
|
providerID: "provider",
|
|
mode: "build",
|
|
agent: "build",
|
|
path: { cwd: "/workspace", root: "/workspace" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
},
|
|
parts: [part],
|
|
} satisfies SessionMessageResponse
|
|
}
|
|
|
|
function runningTool(
|
|
sessionID: string,
|
|
callID: string,
|
|
output?: string,
|
|
input: Record<string, unknown> = { cmd: "printf hello" },
|
|
) {
|
|
return {
|
|
id: `part_${callID}`,
|
|
sessionID,
|
|
messageID: `msg_${callID}`,
|
|
type: "tool",
|
|
callID,
|
|
tool: "bash",
|
|
state: {
|
|
status: "running",
|
|
input,
|
|
title: "bash",
|
|
...(output !== undefined ? { metadata: { output } } : {}),
|
|
time: { start: Date.now() },
|
|
},
|
|
} satisfies ToolPart
|
|
}
|
|
|
|
function completedTool(
|
|
sessionID: string,
|
|
callID: string,
|
|
output = "done",
|
|
attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
|
|
options: {
|
|
readonly tool?: string
|
|
readonly input?: Record<string, unknown>
|
|
readonly metadata?: Record<string, unknown>
|
|
} = {},
|
|
) {
|
|
return {
|
|
id: `part_${callID}`,
|
|
sessionID,
|
|
messageID: `msg_${callID}`,
|
|
type: "tool",
|
|
callID,
|
|
tool: options.tool ?? "bash",
|
|
state: {
|
|
status: "completed",
|
|
input: options.input ?? { cmd: "printf done" },
|
|
output,
|
|
title: "bash",
|
|
metadata: options.metadata ?? { exit: 0 },
|
|
time: { start: Date.now() - 1, end: Date.now() },
|
|
...(attachments.length ? { attachments } : {}),
|
|
},
|
|
} satisfies ToolPart
|
|
}
|
|
|
|
function errorTool(sessionID: string, callID: string) {
|
|
return {
|
|
id: `part_${callID}`,
|
|
sessionID,
|
|
messageID: `msg_${callID}`,
|
|
type: "tool",
|
|
callID,
|
|
tool: "bash",
|
|
state: {
|
|
status: "error",
|
|
input: { cmd: "exit 1" },
|
|
error: "failed hard",
|
|
metadata: { exit: 1 },
|
|
time: { start: Date.now() - 1, end: Date.now() },
|
|
},
|
|
} satisfies ToolPart
|
|
}
|
|
|
|
function toolUpdates(updates: SessionUpdateParams[]) {
|
|
return updates.filter((item): item is ToolSessionUpdateParams => {
|
|
return item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
|
|
})
|
|
}
|
|
|
|
async function createKnownSession(
|
|
session: ACPSession.Interface,
|
|
sessionId: string,
|
|
part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
|
|
) {
|
|
await Effect.runPromise(session.create({ id: sessionId, cwd: "/workspace" }))
|
|
await Effect.runPromise(
|
|
session.recordPartMetadata({
|
|
sessionId,
|
|
messageId: part.messageId,
|
|
partId: part.partId,
|
|
partType: part.partType,
|
|
role: part.role ?? "assistant",
|
|
}),
|
|
)
|
|
}
|
|
|
|
describe("acp event routing", () => {
|
|
it("routes message.part.delta by sessionID without cross-session pollution", async () => {
|
|
const harness = createHarness()
|
|
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
|
|
await createKnownSession(harness.session, "ses_b", { messageId: "msg_b", partId: "part_b", partType: "text" })
|
|
|
|
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "hello"))
|
|
|
|
expect(harness.updates.map((update) => update.sessionId)).toEqual(["ses_b"])
|
|
expect(harness.updates[0]?.update.sessionUpdate).toBe("agent_message_chunk")
|
|
})
|
|
|
|
it("keeps interleaved sessions isolated for text and reasoning deltas", async () => {
|
|
const harness = createHarness()
|
|
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
|
|
await createKnownSession(harness.session, "ses_b", {
|
|
messageId: "msg_b",
|
|
partId: "part_b",
|
|
partType: "reasoning",
|
|
})
|
|
|
|
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A1"))
|
|
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B1"))
|
|
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A2"))
|
|
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B2"))
|
|
|
|
expect(
|
|
harness.updates.filter((update) => update.sessionId === "ses_a").map((update) => update.update.sessionUpdate),
|
|
).toEqual(["agent_message_chunk", "agent_message_chunk"])
|
|
expect(
|
|
harness.updates.filter((update) => update.sessionId === "ses_b").map((update) => update.update.sessionUpdate),
|
|
).toEqual(["agent_thought_chunk", "agent_thought_chunk"])
|
|
})
|
|
|
|
it("does not create extra subscriptions on repeated loadSession", async () => {
|
|
const harness = createHarness()
|
|
let subscription: ACPEvent.Subscription | undefined
|
|
const service = ACPService.make({
|
|
sdk: harness.sdk,
|
|
connection: harness.connection,
|
|
directory: {
|
|
get: () =>
|
|
Effect.succeed(
|
|
Directory.build({
|
|
directory: "/workspace",
|
|
providers: {},
|
|
modes: [],
|
|
defaultModeID: "build",
|
|
commands: [],
|
|
}),
|
|
),
|
|
refresh: () =>
|
|
Effect.succeed(
|
|
Directory.build({
|
|
directory: "/workspace",
|
|
providers: {},
|
|
modes: [],
|
|
defaultModeID: "build",
|
|
commands: [],
|
|
}),
|
|
),
|
|
variants: Directory.variants,
|
|
},
|
|
session: harness.session,
|
|
eventSubscription: (started) => {
|
|
subscription = started
|
|
},
|
|
})
|
|
|
|
await pollUntil(() => harness.calls.eventSubscribe === 1, "event subscription did not start")
|
|
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
|
|
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
|
|
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
|
|
|
|
expect(harness.calls.eventSubscribe).toBe(1)
|
|
subscription?.stop()
|
|
harness.events.close()
|
|
})
|
|
|
|
it("does not call sdk.session.message repeatedly when metadata is known", async () => {
|
|
const harness = createHarness()
|
|
await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
|
|
|
|
for (const delta of ["a", "b", "c", "d", "e"]) {
|
|
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", delta))
|
|
}
|
|
|
|
expect(harness.calls.message).toBe(0)
|
|
expect(harness.updates).toHaveLength(5)
|
|
})
|
|
|
|
it("fetches unknown part metadata once and reuses it for later deltas", async () => {
|
|
const harness = createHarness({
|
|
msg_a: assistantMessage("ses_a", "msg_a", "part_a", "text"),
|
|
})
|
|
await Effect.runPromise(harness.session.create({ id: "ses_a", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(partUpdated("ses_a", "msg_a", "part_a", "text"))
|
|
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "a"))
|
|
await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "b"))
|
|
|
|
expect(harness.calls.message).toBe(1)
|
|
expect(harness.updates).toHaveLength(2)
|
|
})
|
|
|
|
it("replays loaded session messages sequentially and continues after update failures", async () => {
|
|
const events = createEventStream()
|
|
const updates: SessionUpdateParams[] = []
|
|
const connection = {
|
|
sessionUpdate: (params: SessionUpdateParams) => {
|
|
if (params.update.sessionUpdate === "tool_call" && params.update.toolCallId === "call_slow") {
|
|
return new Promise<void>((resolve) => {
|
|
setTimeout(() => {
|
|
updates.push(params)
|
|
resolve()
|
|
}, 20)
|
|
})
|
|
}
|
|
|
|
if (params.update.sessionUpdate === "tool_call_update" && params.update.toolCallId === "call_slow") {
|
|
return Promise.reject(new Error("replay send failed"))
|
|
}
|
|
|
|
updates.push(params)
|
|
return Promise.resolve()
|
|
},
|
|
} satisfies Pick<AgentSideConnection, "sessionUpdate">
|
|
let subscription: ACPEvent.Subscription | undefined
|
|
const service = ACPService.make({
|
|
sdk: {
|
|
global: {
|
|
event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
|
|
},
|
|
session: {
|
|
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
|
|
messages: () =>
|
|
Promise.resolve({
|
|
data: [
|
|
assistantToolMessage(completedTool("ses_loaded", "call_slow", "slow")),
|
|
assistantToolMessage(completedTool("ses_loaded", "call_after", "after")),
|
|
],
|
|
}),
|
|
},
|
|
} as unknown as OpencodeClient,
|
|
connection,
|
|
directory: {
|
|
get: () =>
|
|
Effect.succeed(
|
|
Directory.build({
|
|
directory: "/workspace",
|
|
providers: {},
|
|
modes: [],
|
|
defaultModeID: "build",
|
|
commands: [],
|
|
}),
|
|
),
|
|
refresh: () =>
|
|
Effect.succeed(
|
|
Directory.build({
|
|
directory: "/workspace",
|
|
providers: {},
|
|
modes: [],
|
|
defaultModeID: "build",
|
|
commands: [],
|
|
}),
|
|
),
|
|
variants: Directory.variants,
|
|
},
|
|
eventSubscription: (started) => {
|
|
subscription = started
|
|
},
|
|
})
|
|
|
|
await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
|
|
|
|
expect(toolUpdates(updates).map((item) => item.update.toolCallId)).toEqual([
|
|
"call_slow",
|
|
"call_after",
|
|
"call_after",
|
|
])
|
|
subscription?.stop()
|
|
events.close()
|
|
})
|
|
|
|
it("ignores unknown sessions and live user parts without user_message_chunk duplication", async () => {
|
|
const harness = createHarness()
|
|
await createKnownSession(harness.session, "ses_user", {
|
|
messageId: "msg_user",
|
|
partId: "part_user",
|
|
partType: "text",
|
|
role: "user",
|
|
})
|
|
|
|
await harness.subscription.handle(textDelta("ses_missing", "msg_missing", "part_missing", "ignored"))
|
|
await harness.subscription.handle(partUpdated("ses_user", "msg_user", "part_live", "text"))
|
|
await harness.subscription.handle(textDelta("ses_user", "msg_user", "part_user", "hello"))
|
|
|
|
expect(harness.updates).toHaveLength(0)
|
|
})
|
|
|
|
it("exposes the shell command on the synthetic pending tool call", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(toolUpdated(runningTool("ses_tool", "call_1", "hello")))
|
|
|
|
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
|
|
"tool_call",
|
|
"tool_call_update",
|
|
])
|
|
expect(harness.updates[0]?.update).toMatchObject({
|
|
status: "pending",
|
|
toolCallId: "call_1",
|
|
title: "printf hello",
|
|
kind: "execute",
|
|
locations: [{ path: "/workspace" }],
|
|
rawInput: { cmd: "printf hello", cwd: "/workspace" },
|
|
})
|
|
expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
|
|
})
|
|
|
|
it("includes available input in the synthetic pending tool call", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_pending_input", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(
|
|
toolUpdated({
|
|
id: "part_call_read",
|
|
sessionID: "ses_pending_input",
|
|
messageID: "msg_call_read",
|
|
type: "tool",
|
|
callID: "call_read",
|
|
tool: "read",
|
|
state: {
|
|
status: "running",
|
|
input: { filePath: "/workspace/file.ts" },
|
|
title: "Read file.ts",
|
|
time: { start: Date.now() },
|
|
},
|
|
} satisfies ToolPart),
|
|
)
|
|
|
|
expect(harness.updates[0]?.update).toMatchObject({
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "call_read",
|
|
status: "pending",
|
|
title: "Read file.ts",
|
|
kind: "read",
|
|
rawInput: { filePath: "/workspace/file.ts" },
|
|
locations: [{ path: "/workspace/file.ts" }],
|
|
})
|
|
})
|
|
|
|
it("does not emit duplicate synthetic pending after a replayed running tool", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.replayMessage(assistantToolMessage(runningTool("ses_replay", "call_replay", "first")))
|
|
await harness.subscription.handle(toolUpdated(runningTool("ses_replay", "call_replay", "second")))
|
|
|
|
expect(toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")).toHaveLength(1)
|
|
expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
|
|
"tool_call",
|
|
"tool_call_update",
|
|
"tool_call_update",
|
|
])
|
|
})
|
|
|
|
it("dedupes shell output snapshots while still sending status-only running updates", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_shell", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
|
|
await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
|
|
|
|
const updates = toolUpdates(harness.updates)
|
|
expect(updates).toHaveLength(3)
|
|
expect(updates[1]?.update).toMatchObject({
|
|
sessionUpdate: "tool_call_update",
|
|
content: [{ type: "content", content: { type: "text", text: "same" } }],
|
|
})
|
|
expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" })
|
|
expect("content" in updates[2]!.update).toBe(false)
|
|
})
|
|
|
|
it("clears shell snapshot marker when a tool returns to pending", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_pending", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
|
|
await harness.subscription.handle(
|
|
toolUpdated({
|
|
id: "part_call_pending",
|
|
sessionID: "ses_pending",
|
|
messageID: "msg_call_pending",
|
|
type: "tool",
|
|
callID: "call_pending",
|
|
tool: "bash",
|
|
state: {
|
|
status: "pending",
|
|
input: { cmd: "printf repeat" },
|
|
raw: '{"cmd":"printf repeat"}',
|
|
},
|
|
}),
|
|
)
|
|
await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
|
|
|
|
expect(
|
|
toolUpdates(harness.updates)
|
|
.filter((item) => item.update.sessionUpdate === "tool_call_update")
|
|
.map((item) => ("content" in item.update ? item.update.content : undefined)),
|
|
).toEqual([
|
|
[{ type: "content", content: { type: "text", text: "repeat" } }],
|
|
[{ type: "content", content: { type: "text", text: "repeat" } }],
|
|
])
|
|
})
|
|
|
|
it("emits completed tool output and rawOutput", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_done", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(toolUpdated(completedTool("ses_done", "call_done", "finished")))
|
|
|
|
expect(harness.updates.at(-1)?.update).toMatchObject({
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "call_done",
|
|
status: "completed",
|
|
content: [{ type: "content", content: { type: "text", text: "finished" } }],
|
|
rawOutput: { output: "finished", metadata: { exit: 0 } },
|
|
})
|
|
})
|
|
|
|
it("emits clean read display content and preserves rawOutput", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_read", cwd: "/workspace" }))
|
|
const output = [
|
|
"<path>/workspace/file.ts</path>",
|
|
"<type>file</type>",
|
|
"<content>",
|
|
"1: import { value } from './value'",
|
|
"2: export { value }",
|
|
"",
|
|
"(End of file - total 2 lines)",
|
|
"</content>",
|
|
].join("\n")
|
|
const metadata = {
|
|
display: {
|
|
type: "file",
|
|
path: "/workspace/file.ts",
|
|
text: "import { value } from './value'\nexport { value }",
|
|
lineStart: 1,
|
|
lineEnd: 2,
|
|
totalLines: 2,
|
|
truncated: false,
|
|
},
|
|
}
|
|
|
|
await harness.subscription.handle(
|
|
toolUpdated(
|
|
completedTool("ses_read", "call_read", output, [], {
|
|
tool: "read",
|
|
input: { filePath: "/workspace/file.ts" },
|
|
metadata,
|
|
}),
|
|
),
|
|
)
|
|
|
|
expect(harness.updates.at(-1)?.update).toMatchObject({
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "call_read",
|
|
status: "completed",
|
|
content: [
|
|
{
|
|
type: "content",
|
|
content: { type: "text", text: "import { value } from './value'\nexport { value }" },
|
|
},
|
|
],
|
|
rawOutput: { output, metadata },
|
|
})
|
|
})
|
|
|
|
it("emits error tool output", async () => {
|
|
const harness = createHarness()
|
|
await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(toolUpdated(errorTool("ses_error", "call_error")))
|
|
|
|
expect(harness.updates.at(-1)?.update).toMatchObject({
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "call_error",
|
|
status: "failed",
|
|
content: [{ type: "content", content: { type: "text", text: "failed hard" } }],
|
|
rawOutput: { error: "failed hard", metadata: { exit: 1 } },
|
|
})
|
|
})
|
|
|
|
it("emits image attachments as ACP image content for live and replayed completed tool updates", async () => {
|
|
const harness = createHarness()
|
|
const image = Buffer.from("image-data").toString("base64")
|
|
const attachment = {
|
|
id: "file_image",
|
|
sessionID: "ses_image",
|
|
messageID: "msg_image",
|
|
type: "file",
|
|
mime: "image/png",
|
|
filename: "image.png",
|
|
url: `data:image/png;base64,${image}`,
|
|
} as const
|
|
await Effect.runPromise(harness.session.create({ id: "ses_image", cwd: "/workspace" }))
|
|
|
|
await harness.subscription.handle(toolUpdated(completedTool("ses_image", "call_live", "live", [attachment])))
|
|
await harness.subscription.replayMessage(
|
|
assistantToolMessage(completedTool("ses_image", "call_replayed", "replayed", [attachment])),
|
|
)
|
|
|
|
expect(
|
|
toolUpdates(harness.updates)
|
|
.filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed")
|
|
.map((item) => ("content" in item.update ? item.update.content : [])),
|
|
).toEqual([
|
|
[
|
|
{ type: "content", content: { type: "text", text: "live" } },
|
|
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
|
|
],
|
|
[
|
|
{ type: "content", content: { type: "text", text: "replayed" } },
|
|
{ type: "content", content: { type: "image", mimeType: "image/png", data: image } },
|
|
],
|
|
])
|
|
})
|
|
})
|