diff --git a/packages/opencode/src/actor/turn.ts b/packages/opencode/src/actor/turn.ts index 2039f1fa3..c933592e1 100644 --- a/packages/opencode/src/actor/turn.ts +++ b/packages/opencode/src/actor/turn.ts @@ -14,6 +14,7 @@ export const runTurn = ( 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. diff --git a/packages/opencode/src/tool/actor.ts b/packages/opencode/src/tool/actor.ts index 80a2807f2..e971f5063 100644 --- a/packages/opencode/src/tool/actor.ts +++ b/packages/opencode/src/tool/actor.ts @@ -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 @@ -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, } } @@ -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, + output: [ + ...(taskNotice ? [taskNotice, ""] : []), + `actor_id: ${op.actor_id} (for resuming to continue this task if needed)`, + "", + ``, + resultText, + "", + ].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 diff --git a/packages/opencode/test/tool/actor.test.ts b/packages/opencode/test/tool/actor.test.ts index c95ab9bbc..6e15c9c0a 100644 --- a/packages/opencode/test/tool/actor.test.ts +++ b/packages/opencode/test/tool/actor.test.ts @@ -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( { @@ -264,7 +281,7 @@ 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, }, }, { @@ -272,16 +289,21 @@ describe("tool.actor", () => { 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") }), ), )