From c5cd7c4f2c6c31c8c166987abbbcea5063e5e8be Mon Sep 17 00:00:00 2001 From: Enzo Date: Mon, 22 Dec 2025 16:30:20 -0500 Subject: [PATCH] feat(session): add SessionRunner abstraction for async subagent delegation PR1 of async subagent delegation stack (Issue #5887). This commit introduces the SessionRunner namespace which provides a unified interface for executing session loops: - runOnce(): Synchronous execution wrapping SessionPrompt.prompt (implemented) - runBackground(): Fire-and-forget execution for async delegation (scaffold only) - Options/RunResult types for future background execution - State tracking infrastructure for active background runs No behavior change - this is purely an abstraction layer. Next: PR2 will implement runBackground() with lifecycle events. --- packages/opencode/src/session/runner.ts | 79 +++++++++++++++++++ packages/opencode/test/session/runner.test.ts | 65 +++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 packages/opencode/src/session/runner.ts create mode 100644 packages/opencode/test/session/runner.test.ts diff --git a/packages/opencode/src/session/runner.ts b/packages/opencode/src/session/runner.ts new file mode 100644 index 00000000000..00a70165553 --- /dev/null +++ b/packages/opencode/src/session/runner.ts @@ -0,0 +1,79 @@ +import z from "zod" +import { Identifier } from "../id/id" +import { Log } from "../util/log" +import { SessionPrompt } from "./prompt" +import { MessageV2 } from "./message-v2" +import { Instance } from "../project/instance" +import { fn } from "@/util/fn" + +export namespace SessionRunner { + const log = Log.create({ service: "session.runner" }) + + export const Options = z + .object({ + model: z.object({ + providerID: z.string(), + modelID: z.string(), + }), + agent: z.string(), + tools: z.record(z.string(), z.boolean()).optional(), + origin: z + .object({ + parentSessionID: Identifier.schema("session").optional(), + parentMessageID: Identifier.schema("message").optional(), + description: z.string().optional(), + command: z.string().optional(), + }) + .optional(), + timeoutMs: z.number().optional(), + maxSteps: z.number().optional(), + }) + .meta({ ref: "SessionRunnerOptions" }) + export type Options = z.infer + + export const RunResult = z + .object({ + sessionID: Identifier.schema("session"), + message: MessageV2.WithParts, + success: z.boolean(), + error: z.string().optional(), + }) + .meta({ ref: "SessionRunnerResult" }) + export type RunResult = z.infer + + const state = Instance.state(() => ({ + active: {} as Record< + string, + { + startedAt: number + options: Options + promise: Promise + } + >, + })) + + export function isRunning(id: string): boolean { + return id in state().active + } + + export function listActive(): string[] { + return Object.keys(state().active) + } + + export const runOnce = fn(SessionPrompt.PromptInput, async (input): Promise => { + log.info("runOnce", { sessionID: input.sessionID, agent: input.agent }) + return SessionPrompt.prompt(input) + }) + + export function runBackground(_id: string, _options: Options): void { + throw new Error("SessionRunner.runBackground not yet implemented") + } + + export function cancelBackground(_id: string): boolean { + throw new Error("SessionRunner.cancelBackground not yet implemented") + } + + export async function waitFor(_id: string): Promise { + throw new Error("SessionRunner.waitFor not yet implemented") + } +} diff --git a/packages/opencode/test/session/runner.test.ts b/packages/opencode/test/session/runner.test.ts new file mode 100644 index 00000000000..a6f0f04f685 --- /dev/null +++ b/packages/opencode/test/session/runner.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { SessionRunner } from "../../src/session/runner" + +describe("SessionRunner", () => { + describe("Options schema", () => { + test("validates valid options", () => { + const valid = { + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" }, + agent: "code", + } + expect(SessionRunner.Options.safeParse(valid).success).toBe(true) + }) + + test("validates options with tools", () => { + const opts = { + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" }, + agent: "code", + tools: { bash: true, read: true, write: false }, + } + expect(SessionRunner.Options.safeParse(opts).success).toBe(true) + }) + + test("validates options with timeout", () => { + const opts = { + model: { providerID: "openai", modelID: "gpt-4" }, + agent: "general", + timeoutMs: 30000, + maxSteps: 10, + } + expect(SessionRunner.Options.safeParse(opts).success).toBe(true) + }) + + test("rejects missing model", () => { + expect(SessionRunner.Options.safeParse({ agent: "code" }).success).toBe(false) + }) + + test("rejects missing agent", () => { + const opts = { model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" } } + expect(SessionRunner.Options.safeParse(opts).success).toBe(false) + }) + + test("rejects invalid model structure", () => { + const opts = { model: { providerID: "anthropic" }, agent: "code" } + expect(SessionRunner.Options.safeParse(opts).success).toBe(false) + }) + }) + + describe("stub methods", () => { + test("runBackground throws", () => { + const opts: SessionRunner.Options = { + model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" }, + agent: "code", + } + expect(() => SessionRunner.runBackground("session_123", opts)).toThrow("not yet implemented") + }) + + test("cancelBackground throws", () => { + expect(() => SessionRunner.cancelBackground("session_123")).toThrow("not yet implemented") + }) + + test("waitFor throws", async () => { + await expect(SessionRunner.waitFor("session_123")).rejects.toThrow("not yet implemented") + }) + }) +})