mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa56a8a71 | |||
| b76df6c359 | |||
| e3366180ae |
@@ -1,4 +1,4 @@
|
||||
import type { AgentSideConnection, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
Event,
|
||||
EventMessagePartDelta,
|
||||
@@ -29,13 +29,6 @@ type GlobalEventEnvelope = {
|
||||
type GlobalEventStream = {
|
||||
stream: AsyncIterable<GlobalEventEnvelope>
|
||||
}
|
||||
type ChildSession = {
|
||||
id: string
|
||||
parentID: string
|
||||
rootID: string
|
||||
depth: number
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
|
||||
const subscription = new Subscription(input)
|
||||
@@ -49,7 +42,6 @@ export class Subscription {
|
||||
private readonly toolStarts = new Set<string>()
|
||||
private readonly connectionWaiters = new Set<() => void>()
|
||||
private readonly idleWaiters = new Map<string, Set<ReturnType<typeof signal>>>()
|
||||
private readonly children = new Map<string, ChildSession>()
|
||||
private readonly permission: ACPPermission.Handler
|
||||
private connected = false
|
||||
private started = false
|
||||
@@ -100,30 +92,12 @@ export class Subscription {
|
||||
|
||||
async handle(event: Event) {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
await this.registerChild(event.properties.info)
|
||||
return
|
||||
case "session.deleted":
|
||||
this.children.delete(event.properties.sessionID)
|
||||
return
|
||||
case "session.status":
|
||||
if (event.properties.status.type === "idle") this.idle(event.properties.sessionID)
|
||||
return
|
||||
case "permission.asked": {
|
||||
const target = await this.resolveSession(event.properties.sessionID)
|
||||
if (!target) return
|
||||
this.permission.handle(event, {
|
||||
sessionId: target.session.id,
|
||||
cwd: target.session.cwd,
|
||||
...(target.child
|
||||
? {
|
||||
toolCallPrefix: target.child.id,
|
||||
titlePrefix: target.child.title,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
case "permission.asked":
|
||||
this.permission.handle(event)
|
||||
return
|
||||
}
|
||||
case "message.part.updated":
|
||||
return this.handlePartUpdated(event)
|
||||
case "message.part.delta":
|
||||
@@ -138,7 +112,7 @@ export class Subscription {
|
||||
for (const part of message.parts) {
|
||||
await this.recordFetchedPart(message.info.sessionID, message, part)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(message.info.sessionID, message.info.sessionID, part, cwd ?? process.cwd())
|
||||
await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd())
|
||||
continue
|
||||
}
|
||||
await this.replayContentPart(message, part)
|
||||
@@ -216,13 +190,13 @@ export class Subscription {
|
||||
|
||||
private async handlePartUpdated(event: EventMessagePartUpdated) {
|
||||
const part = event.properties.part
|
||||
const sourceSessionId = part.sessionID || event.properties.sessionID
|
||||
const target = await this.resolveSession(sourceSessionId)
|
||||
if (!target) return
|
||||
const sessionId = part.sessionID || event.properties.sessionID
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (!session) return
|
||||
|
||||
await Effect.runPromise(
|
||||
this.input.session.recordPartMetadata({
|
||||
sessionId: target.session.id,
|
||||
sessionId: session.id,
|
||||
messageId: part.messageID,
|
||||
partId: part.id,
|
||||
partType: part.type,
|
||||
@@ -233,18 +207,18 @@ export class Subscription {
|
||||
}),
|
||||
)
|
||||
if (part.type === "tool") {
|
||||
await this.handleToolPart(target.session.id, sourceSessionId, part, target.session.cwd, target.child)
|
||||
await this.handleToolPart(session.id, part, session.cwd)
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePartDelta(event: EventMessagePartDelta) {
|
||||
const props = event.properties
|
||||
const target = await this.resolveSession(props.sessionID)
|
||||
if (!target) return
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(props.sessionID))
|
||||
if (!session) return
|
||||
|
||||
const known = await Effect.runPromise(
|
||||
this.input.session.tryGetPartMetadata({
|
||||
sessionId: target.session.id,
|
||||
sessionId: session.id,
|
||||
messageId: props.messageID,
|
||||
partId: props.partID,
|
||||
}),
|
||||
@@ -252,18 +226,12 @@ export class Subscription {
|
||||
const metadata =
|
||||
known?.role && known.partType
|
||||
? known
|
||||
: await this.fetchPartMetadata(
|
||||
props.sessionID,
|
||||
target.session.id,
|
||||
target.session.cwd,
|
||||
props.messageID,
|
||||
props.partID,
|
||||
)
|
||||
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
|
||||
if (metadata?.role !== "assistant") return
|
||||
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
|
||||
await this.update(
|
||||
target.session.id,
|
||||
{
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
@@ -271,15 +239,14 @@ export class Subscription {
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
target.child,
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.partType === "reasoning" && props.field === "text") {
|
||||
await this.update(
|
||||
target.session.id,
|
||||
{
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId: session.id,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: props.messageID,
|
||||
content: {
|
||||
@@ -287,22 +254,15 @@ export class Subscription {
|
||||
text: props.delta,
|
||||
},
|
||||
},
|
||||
target.child,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPartMetadata(
|
||||
sourceSessionId: string,
|
||||
targetSessionId: string,
|
||||
cwd: string,
|
||||
messageId: string,
|
||||
partId: string,
|
||||
) {
|
||||
private async fetchPartMetadata(sessionId: string, cwd: string, messageId: string, partId: string) {
|
||||
const message = await this.input.sdk.session
|
||||
.message(
|
||||
{
|
||||
sessionID: sourceSessionId,
|
||||
sessionID: sessionId,
|
||||
messageID: messageId,
|
||||
directory: cwd,
|
||||
},
|
||||
@@ -314,7 +274,7 @@ export class Subscription {
|
||||
|
||||
const part = message.parts.find((item) => item.id === partId)
|
||||
if (!part) return
|
||||
return await this.recordFetchedPart(targetSessionId, message, part)
|
||||
return await this.recordFetchedPart(sessionId, message, part)
|
||||
}
|
||||
|
||||
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
|
||||
@@ -332,30 +292,23 @@ export class Subscription {
|
||||
)
|
||||
}
|
||||
|
||||
private async handleToolPart(
|
||||
sessionId: string,
|
||||
sourceSessionId: string,
|
||||
part: ToolPart,
|
||||
cwd: string,
|
||||
child?: ChildSession,
|
||||
) {
|
||||
const key = toolKey(sourceSessionId, part.callID)
|
||||
await this.toolStart(sessionId, key, part, cwd, child)
|
||||
private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
await this.toolStart(sessionId, part, cwd)
|
||||
|
||||
switch (part.state.status) {
|
||||
case "pending":
|
||||
this.shellSnapshots.delete(key)
|
||||
this.shellSnapshots.delete(part.callID)
|
||||
return
|
||||
|
||||
case "running":
|
||||
await this.runningTool(sessionId, key, part, cwd, child)
|
||||
await this.runningTool(sessionId, part, cwd)
|
||||
return
|
||||
|
||||
case "completed":
|
||||
this.clearTool(key)
|
||||
await this.update(
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
{
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -364,15 +317,14 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
})
|
||||
return
|
||||
|
||||
case "error":
|
||||
this.clearTool(key)
|
||||
await this.update(
|
||||
this.clearTool(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
{
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -381,21 +333,20 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async runningTool(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
|
||||
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
|
||||
if (part.state.status !== "running") return
|
||||
|
||||
const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
|
||||
if (output !== undefined) {
|
||||
if (this.shellSnapshots.get(key) === output) {
|
||||
await this.update(
|
||||
if (this.shellSnapshots.get(part.callID) === output) {
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
{
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...duplicateRunningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -404,16 +355,15 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
this.shellSnapshots.set(key, output)
|
||||
this.shellSnapshots.set(part.callID, output)
|
||||
}
|
||||
|
||||
await this.update(
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
{
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.callID,
|
||||
@@ -423,16 +373,15 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async toolStart(sessionId: string, key: string, part: ToolPart, cwd: string, child?: ChildSession) {
|
||||
if (this.toolStarts.has(key)) return
|
||||
this.toolStarts.add(key)
|
||||
await this.update(
|
||||
private async toolStart(sessionId: string, part: ToolPart, cwd: string) {
|
||||
if (this.toolStarts.has(part.callID)) return
|
||||
this.toolStarts.add(part.callID)
|
||||
await this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
{
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.callID,
|
||||
@@ -441,67 +390,13 @@ export class Subscription {
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
child,
|
||||
)
|
||||
}
|
||||
|
||||
private clearTool(key: string) {
|
||||
this.toolStarts.delete(key)
|
||||
this.shellSnapshots.delete(key)
|
||||
}
|
||||
|
||||
private async registerChild(info: Extract<Event, { type: "session.created" }>["properties"]["info"]) {
|
||||
if (!info.parentID) return
|
||||
const parent = this.children.get(info.parentID)
|
||||
const rootID = parent?.rootID ?? (await Effect.runPromise(this.input.session.tryGet(info.parentID)))?.id
|
||||
if (!rootID) return
|
||||
this.children.set(info.id, {
|
||||
id: info.id,
|
||||
parentID: info.parentID,
|
||||
rootID,
|
||||
depth: (parent?.depth ?? 0) + 1,
|
||||
title: info.title,
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveSession(sessionId: string) {
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(sessionId))
|
||||
if (session) return { session, child: undefined }
|
||||
const child = this.children.get(sessionId)
|
||||
if (!child) return
|
||||
const root = await Effect.runPromise(this.input.session.tryGet(child.rootID))
|
||||
if (!root) return
|
||||
return { session: root, child }
|
||||
private clearTool(toolCallId: string) {
|
||||
this.toolStarts.delete(toolCallId)
|
||||
this.shellSnapshots.delete(toolCallId)
|
||||
}
|
||||
|
||||
private update(sessionId: string, update: SessionUpdate, child?: ChildSession) {
|
||||
return this.input.connection.sessionUpdate({
|
||||
sessionId,
|
||||
update: child ? projectChildUpdate(update, child) : update,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toolKey(sessionId: string, toolCallId: string) {
|
||||
return `${sessionId}:${toolCallId}`
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
function signal() {
|
||||
|
||||
@@ -16,12 +16,6 @@ import { Effect } from "effect"
|
||||
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
|
||||
type Reply = "once" | "always" | "reject"
|
||||
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
|
||||
export type Context = {
|
||||
sessionId: string
|
||||
cwd: string
|
||||
toolCallPrefix?: string
|
||||
titlePrefix?: string
|
||||
}
|
||||
|
||||
const permissionOptions: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
@@ -40,11 +34,11 @@ export class Handler {
|
||||
},
|
||||
) {}
|
||||
|
||||
handle(event: PermissionEvent, context?: Context) {
|
||||
handle(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
|
||||
const next = previous
|
||||
.then(() => this.process(event, context))
|
||||
.then(() => this.process(event))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.queues.get(permission.sessionID) === next) {
|
||||
@@ -54,31 +48,28 @@ export class Handler {
|
||||
this.queues.set(permission.sessionID, next)
|
||||
}
|
||||
|
||||
private async process(event: PermissionEvent, context?: Context) {
|
||||
private async process(event: PermissionEvent) {
|
||||
const permission = event.properties
|
||||
const registered = context ? undefined : await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
const target = context ?? (registered ? { sessionId: registered.id, cwd: registered.cwd } : undefined)
|
||||
if (!target) return
|
||||
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
|
||||
if (!session) return
|
||||
|
||||
if (!this.input.connection.requestPermission) {
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
const toolCallId = permission.tool?.callID ?? permission.id
|
||||
const result = await this.input.connection
|
||||
.requestPermission({
|
||||
sessionId: target.sessionId,
|
||||
sessionId: permission.sessionID,
|
||||
toolCall: await permissionToolCall({
|
||||
toolCallId: target.toolCallPrefix ? `${target.toolCallPrefix}:${toolCallId}` : toolCallId,
|
||||
toolCallId: permission.tool?.callID ?? permission.id,
|
||||
toolName: permission.permission,
|
||||
input: permission.metadata,
|
||||
titlePrefix: target.titlePrefix,
|
||||
}),
|
||||
options: permissionOptions,
|
||||
})
|
||||
.catch(async () => {
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -86,15 +77,15 @@ export class Handler {
|
||||
|
||||
const reply = selectedReply(result)
|
||||
if (reply !== "once" && reply !== "always") {
|
||||
await this.reply(permission.id, "reject", target.cwd)
|
||||
await this.reply(permission.id, "reject", session.cwd)
|
||||
return
|
||||
}
|
||||
|
||||
if (permission.permission === "edit") {
|
||||
await this.writeProposedEdit(target.sessionId, permission.metadata).catch(() => {})
|
||||
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
|
||||
}
|
||||
|
||||
await this.reply(permission.id, reply, target.cwd)
|
||||
await this.reply(permission.id, reply, session.cwd)
|
||||
}
|
||||
|
||||
private async reply(requestID: string, reply: Reply, directory: string) {
|
||||
@@ -128,7 +119,6 @@ async function permissionToolCall(input: {
|
||||
readonly toolCallId: string
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly titlePrefix?: string
|
||||
}): Promise<ToolCallUpdate> {
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.toolCallId,
|
||||
@@ -141,18 +131,11 @@ async function permissionToolCall(input: {
|
||||
const content = await permissionContent(input.toolName, input.input)
|
||||
return {
|
||||
...toolCall,
|
||||
title: prefixedTitle(input.titlePrefix, toolCall.title),
|
||||
locations: permissionLocations(input.toolName, input.input),
|
||||
...(content.length ? { content } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
if (!prefix) return title
|
||||
if (!title) return prefix
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
switch (tool) {
|
||||
|
||||
@@ -28,6 +28,13 @@ export const RETRY_BACKOFF_FACTOR = 2
|
||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
||||
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
|
||||
|
||||
const RETRYABLE_MESSAGE = [
|
||||
/\b(?:server[_\s-]?error|internal[_\s-]?error|service[_\s-]?unavailable|overloaded|too many requests|rate increased too quickly|provider[_\s-]?returned[_\s-]?error)\b|\brate[_\s-]?limit/i,
|
||||
/\b(?:fetch failed|network error|upstream connect|connection (?:error|refused|lost)|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again)\b|^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed? out)\b/i,
|
||||
/\b(?:resource[_\s-]?exhausted|please retry your request|you can retry your request|try your request again)\b/i,
|
||||
/\b(?:429|500|502|503|504|524)\b/,
|
||||
]
|
||||
|
||||
function cap(ms: number) {
|
||||
return Math.min(ms, RETRY_MAX_DELAY)
|
||||
}
|
||||
@@ -122,32 +129,12 @@ export function retryable(error: Err, provider: string) {
|
||||
return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message }
|
||||
}
|
||||
|
||||
// Check for rate limit patterns in plain text error messages
|
||||
const msg = isRecord(error.data) ? error.data.message : undefined
|
||||
if (typeof msg === "string") {
|
||||
const lower = msg.toLowerCase()
|
||||
if (
|
||||
lower.includes("rate increased too quickly") ||
|
||||
lower.includes("rate limit") ||
|
||||
lower.includes("too many requests")
|
||||
) {
|
||||
return { message: msg }
|
||||
}
|
||||
}
|
||||
|
||||
const json = parseJSON(msg)
|
||||
if (!json || typeof json !== "object") return undefined
|
||||
const code = typeof json.code === "string" ? json.code : ""
|
||||
|
||||
if (json.type === "error" && json.error?.type === "too_many_requests") {
|
||||
return { message: "Too Many Requests" }
|
||||
}
|
||||
if (code.includes("exhausted") || code.includes("unavailable")) {
|
||||
return { message: "Provider is overloaded" }
|
||||
}
|
||||
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
|
||||
return { message: "Rate Limited" }
|
||||
}
|
||||
const message = isRecord(error.data) ? error.data.message : undefined
|
||||
if (typeof message !== "string") return undefined
|
||||
const lower = message.toLowerCase()
|
||||
if (lower.includes("too_many_requests")) return { message: "Too Many Requests" }
|
||||
if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" }
|
||||
if (RETRYABLE_MESSAGE.some((pattern) => pattern.test(message))) return { message }
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -167,21 +167,6 @@ function toolUpdated(part: ToolPart): Event {
|
||||
}
|
||||
}
|
||||
|
||||
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
|
||||
return {
|
||||
id: `evt_${sessionID}_created`,
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
title,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
|
||||
return {
|
||||
info: {
|
||||
@@ -334,63 +319,6 @@ async function createKnownSession(
|
||||
}
|
||||
|
||||
describe("acp event routing", () => {
|
||||
it("projects nested subagent messages onto the root session", async () => {
|
||||
const harness = createHarness({
|
||||
msg_grandchild: assistantMessage("ses_grandchild", "msg_grandchild", "part_grandchild", "text"),
|
||||
})
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
await harness.subscription.handle(sessionCreated("ses_grandchild", "ses_child", "Research"))
|
||||
|
||||
await harness.subscription.handle(partUpdated("ses_grandchild", "msg_grandchild", "part_grandchild", "text"))
|
||||
await harness.subscription.handle(textDelta("ses_grandchild", "msg_grandchild", "part_grandchild", "nested result"))
|
||||
|
||||
expect(harness.updates).toEqual([
|
||||
{
|
||||
sessionId: "ses_root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: "msg_grandchild",
|
||||
content: { type: "text", text: "nested result" },
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_grandchild",
|
||||
parentID: "ses_child",
|
||||
depth: 2,
|
||||
title: "Research",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("namespaces subagent tool calls without colliding with root tools", async () => {
|
||||
const harness = createHarness()
|
||||
await Effect.runPromise(harness.session.create({ id: "ses_root", cwd: "/workspace" }))
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
|
||||
await harness.subscription.handle(toolUpdated(runningTool("ses_root", "call_shared", "root")))
|
||||
await harness.subscription.handle(toolUpdated(runningTool("ses_child", "call_shared", "child")))
|
||||
|
||||
const starts = toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")
|
||||
expect(starts.map((item) => item.update.toolCallId)).toEqual(["call_shared", "ses_child:call_shared"])
|
||||
expect(starts[1]).toMatchObject({
|
||||
sessionId: "ses_root",
|
||||
update: {
|
||||
title: "Explore: printf hello",
|
||||
_meta: {
|
||||
"opencode/child-session": {
|
||||
id: "ses_child",
|
||||
parentID: "ses_root",
|
||||
depth: 1,
|
||||
title: "Explore",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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" })
|
||||
|
||||
@@ -122,21 +122,6 @@ function permissionAsked(
|
||||
} as PermissionEvent
|
||||
}
|
||||
|
||||
function sessionCreated(sessionID: string, parentID: string, title: string): Event {
|
||||
return {
|
||||
id: `evt_${sessionID}_created`,
|
||||
type: "session.created",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
title,
|
||||
},
|
||||
},
|
||||
} as Event
|
||||
}
|
||||
|
||||
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
|
||||
return {
|
||||
id: `evt_${sessionID}_${messageID}_${partID}`,
|
||||
@@ -171,29 +156,6 @@ async function tempFile(name: string, content: string) {
|
||||
}
|
||||
|
||||
describe("acp permissions", () => {
|
||||
it("routes subagent permissions through the root session", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_root")
|
||||
await harness.subscription.handle(sessionCreated("ses_child", "ses_root", "Explore"))
|
||||
|
||||
harness.subscription.handle(
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
tool: { messageID: "msg_child", callID: "call_child" },
|
||||
}),
|
||||
)
|
||||
|
||||
await pollUntil(() => harness.replies.length === 1, "subagent permission was never replied")
|
||||
|
||||
expect(harness.requests[0]).toMatchObject({
|
||||
sessionId: "ses_root",
|
||||
toolCall: {
|
||||
toolCallId: "ses_child:call_child",
|
||||
title: "Explore: printf hello",
|
||||
},
|
||||
})
|
||||
expect(harness.replies).toEqual([{ requestID: "perm_child", reply: "once", directory: "/workspace" }])
|
||||
})
|
||||
|
||||
it("sends requestPermission and replies with the selected outcome", async () => {
|
||||
const harness = createHarness()
|
||||
await createSession(harness.session, "ses_a")
|
||||
|
||||
@@ -118,16 +118,21 @@ describe("session.retry.delay", () => {
|
||||
})
|
||||
|
||||
describe("session.retry.retryable", () => {
|
||||
test("maps too_many_requests json messages", () => {
|
||||
test("retries serialized too_many_requests messages", () => {
|
||||
const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" })
|
||||
})
|
||||
|
||||
test("maps overloaded provider codes", () => {
|
||||
test("retries serialized overloaded provider codes", () => {
|
||||
const error = wrap(JSON.stringify({ code: "resource_exhausted" }))
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" })
|
||||
})
|
||||
|
||||
test("retries serialized rate_limit messages", () => {
|
||||
const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } })
|
||||
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
|
||||
})
|
||||
|
||||
test("does not retry unknown json messages", () => {
|
||||
const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } }))
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined()
|
||||
@@ -163,6 +168,19 @@ describe("session.retry.retryable", () => {
|
||||
expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg })
|
||||
})
|
||||
|
||||
test.each([
|
||||
"Internal server error",
|
||||
"Provider returned error",
|
||||
"fetch failed",
|
||||
"connection refused",
|
||||
"EAI_AGAIN",
|
||||
"response timed out",
|
||||
"Please retry your request",
|
||||
"upstream returned status 524",
|
||||
])("retries matching API error text: %s", (message) => {
|
||||
expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message })
|
||||
})
|
||||
|
||||
test("retries transport timeout errors", () => {
|
||||
const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID })
|
||||
expect(SessionV1.APIError.isInstance(request)).toBe(true)
|
||||
|
||||
Reference in New Issue
Block a user