Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/opencode/src/actor/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const runTurn = <A, E>(
Effect.gen(function* () {
const reg = yield* ActorRegistry.Service
yield* reg.updateStatus(sessionID, actorID, { status: "running" }).pipe(Effect.ignore)
yield* reg.updateTurn(sessionID, actorID).pipe(Effect.ignore)
// Run work interruptibly so it can be cancelled by Fiber.interrupt.
// Effect.exit captures the outcome without re-raising, letting us
// write status unconditionally before propagating the cause.
Expand Down
71 changes: 71 additions & 0 deletions packages/opencode/src/tool/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { TaskID } from "@/task/schema"
import { SessionCheckpoint } from "@/session/checkpoint"
import { inboxServiceRef } from "@/inbox/inbox-ref"
import { Effect, Deferred } from "effect"
import { EffectLogger } from "@/effect"
import { runTurn } from "@/actor/turn"

export interface ActorPromptOps {
cancel(sessionID: SessionID): void
Expand Down Expand Up @@ -555,6 +557,8 @@ export const ActorTool = Tool.define(
inboxID: sendResult.inboxID,
receiver_actor_id: op.to_actor_id,
receiver_session_id: targetSid,
sessionId: targetSid,
actorId: op.to_actor_id,
} as Record<string, any>,
}
}
Expand Down Expand Up @@ -735,6 +739,73 @@ export const ActorTool = Tool.define(
}
}

if (op.actor_id) {
const found = yield* findActor(op.actor_id)
if (found?.entry.status === "idle") {
const promptOps = (ctx.extra as any)?.promptOps as ActorPromptOps | undefined
if (!promptOps) {
return yield* Effect.fail(new Error("Actor prompt operations unavailable — cannot resume actor"))
}
const promptInput: SessionPrompt.PromptInput = {
sessionID: ctx.sessionID,
agent: op.subagent_type,
agentID: op.actor_id,
parts: [{ type: "text", text: prompt }],
model,
...(op.output_schema
? { format: { type: "json_schema" as const, schema: op.output_schema, retryCount: 2 } }
: {}),
...(effectiveTaskId ? { task_id: effectiveTaskId } : {}),
}

yield* ctx.metadata({
title: op.description,
metadata: {
sessionId: ctx.sessionID,
actorId: op.actor_id,
model,
},
})

if (op.action === "spawn") {
Effect.runFork(
runTurn(ctx.sessionID, op.actor_id, promptOps.prompt(promptInput)).pipe(
Effect.provideService(ActorRegistry.Service, actorRegistry),
Effect.provide(EffectLogger.layer),
),
)
return {
title: op.description,
metadata: { sessionId: ctx.sessionID, actorId: op.actor_id, model },
output:
(taskNotice ? taskNotice + "\n" : "") +
`Background actor resumed. actor_id: ${op.actor_id}\nThe result will be delivered as a notification when complete.`,
}
}

const resumed = yield* runTurn(
ctx.sessionID,
op.actor_id,
promptOps.prompt(promptInput),
).pipe(Effect.provideService(ActorRegistry.Service, actorRegistry))
const textPart = resumed.parts.filter((part) => part.type === "text").at(-1)
const structured = resumed.info.role === "assistant" ? resumed.info.structured : undefined
const resultText = structured !== undefined ? JSON.stringify(structured) : (textPart?.text ?? "(no output)")
return {
title: op.description,
metadata: { sessionId: ctx.sessionID, actorId: op.actor_id, model } as Record<string, any>,
output: [
...(taskNotice ? [taskNotice, ""] : []),
`actor_id: ${op.actor_id} (for resuming to continue this task if needed)`,
"",
`<actor_result status="success">`,
resultText,
"</actor_result>",
].join("\n"),
}
}
}

// v6: subagents share the parent's sessionID and run as registered actors
// under the parent. Actor.spawn handles registry registration, forking
// the agent loop, and sending inbox notifications on terminal — replacing
Expand Down
34 changes: 28 additions & 6 deletions packages/opencode/test/tool/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,30 @@ describe("tool.actor", () => {
),
)

it.live("execute resumes an existing task session from actor_id", () =>
it.live("execute resumes an idle subagent from actor_id", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
yield* installMockSpawn()
let spawns = 0
yield* installMockSpawn(() => {
spawns++
})
const { chat, assistant } = yield* seed()
const registry = yield* ActorRegistry.Service
const actorID = yield* registry.allocateActorID(chat.id, "general")
yield* registry.register({
sessionID: chat.id,
actorID,
mode: "subagent",
agent: "general",
description: "inspect bug",
contextMode: "none",
background: false,
lifecycle: "ephemeral",
})
yield* registry.updateStatus(chat.id, actorID, { status: "idle", lastOutcome: "success" })
const tool = yield* ActorTool
const def = yield* tool.init()
const prompts: SessionPrompt.PromptInput[] = []

const result = yield* def.execute(
{
Expand All @@ -264,24 +281,29 @@ describe("tool.actor", () => {
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
actor_id: "ses_missing", // v9: actor_id in run action is ignored — always creates new
actor_id: actorID,
},
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {},
extra: { promptOps: stubOps({ onPrompt: (input) => prompts.push(input), text: "resumed done" }) },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)

// v9: run always creates a new actor under the parent session
expect(spawns).toBe(0)
expect(prompts).toHaveLength(1)
expect(prompts[0]?.agentID).toBe(actorID)
expect(prompts[0]?.agent).toBe("general")
expect(result.metadata.sessionId).toBe(chat.id)
expect(result.output).toContain("actor_id:")
expect(result.metadata.actorId).toBe(actorID)
expect(result.output).toContain(`actor_id: ${actorID}`)
expect(result.output).toContain("resumed done")
}),
),
)
Expand Down