From 4f33e5b6cccf62bbc9238969bb82134fad072a68 Mon Sep 17 00:00:00 2001 From: faceair Date: Sun, 5 Jul 2026 06:50:02 +0800 Subject: [PATCH] feat: implement actor_id resume in run/spawn + fix turnCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actor.ts: add resume branch in run/spawn execute path — when op.actor_id exists and findActor finds an idle actor, call promptOps.prompt with the existing actorID instead of spawning a new actor. run blocks on result; spawn uses Effect.runFork for fire-and-forget. Both wrapped in runTurn for registry status/turnCount/lastOutcome updates. - actor.ts: add sessionId/actorId to send metadata for TUI card navigation - turn.ts: call registry.updateTurn in runTurn so turnCount increments on every turn (was defined but never called — turnCount was always 0) - actor.test.ts: update dead-code test to real resume test - spawn resume: provide EffectLogger.layer to prevent stdout log pollution --- packages/opencode/src/actor/turn.ts | 1 + packages/opencode/src/tool/actor.ts | 71 +++++++++++++++++++++++ packages/opencode/test/tool/actor.test.ts | 34 +++++++++-- 3 files changed, 100 insertions(+), 6 deletions(-) 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") }), ), )