From d5bcfae27ce7b1330cf102d78501b23a9a63c31c Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:27:59 +0200 Subject: [PATCH 1/5] feat(opencode): add dispatch controls to the task tool Per-dispatch model override (permission-gated), resume that keeps model and variant, slug task_ids, per-dispatch variant, opaque metadata, an explicit resume consent gate, and timeout with fallback_model. --- packages/core/src/v1/config/permission.ts | 1 + packages/opencode/src/agent/agent.ts | 1 + packages/opencode/src/provider/transform.ts | 11 +- packages/opencode/src/session/prompt.ts | 5 + packages/opencode/src/session/run-state.ts | 17 +- packages/opencode/src/session/session.ts | 27 +- packages/opencode/src/tool/task.ts | 181 +++- packages/opencode/src/tool/task.txt | 15 +- .../opencode/test/provider/transform.test.ts | 19 + .../__snapshots__/parameters.test.ts.snap | 29 +- .../opencode/test/tool/parameters.test.ts | 32 + packages/opencode/test/tool/task.test.ts | 998 ++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 13 files changed, 1319 insertions(+), 18 deletions(-) diff --git a/packages/core/src/v1/config/permission.ts b/packages/core/src/v1/config/permission.ts index 475dc7bbf3f2..d387619b6866 100644 --- a/packages/core/src/v1/config/permission.ts +++ b/packages/core/src/v1/config/permission.ts @@ -28,6 +28,7 @@ const InputObject = Schema.StructWithRest( question: Schema.optional(Action), webfetch: Schema.optional(Action), websearch: Schema.optional(Action), + model_override: Schema.optional(Rule), lsp: Schema.optional(Rule), doom_loop: Schema.optional(Action), skill: Schema.optional(Rule), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 536a642fe49f..becb8b212ad7 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -119,6 +119,7 @@ const layer = Layer.effect( const defaults = Permission.fromConfig({ "*": "allow", doom_loop: "ask", + model_override: "deny", external_directory: { "*": "ask", ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0667fc2eb098..1ec0cda91188 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -932,11 +932,18 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) } + const isDeepseekV4 = model.api.id.toLowerCase().includes("deepseek-v4") const efforts = [...WIDELY_SUPPORTED_EFFORTS] - if (model.api.id.toLowerCase().includes("deepseek-v4")) { + if (isDeepseekV4) { efforts.push("max") } - return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + const result: Record> = Object.fromEntries( + efforts.map((effort) => [effort, { reasoningEffort: effort }]), + ) + if (isDeepseekV4) { + result.none = { thinking: { type: "disabled" } } + } + return result case "@ai-sdk/azure": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..5242cc2edb13 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -144,6 +144,7 @@ const layer = Layer.effect( const ops = Effect.fn("SessionPrompt.ops")(function* () { return { cancel: (sessionID: SessionID) => cancel(sessionID), + cancelRun: (sessionID: SessionID) => cancelRun(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), } satisfies TaskPromptOps @@ -154,6 +155,10 @@ const layer = Layer.effect( yield* state.cancel(sessionID) }) + const cancelRun = Effect.fn("SessionPrompt.cancelRun")(function* (sessionID: SessionID) { + yield* state.cancelRun(sessionID) + }) + const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { const ctx = yield* InstanceState.context const parts: Types.DeepMutable = [{ type: "text", text: template }] diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 5cefdd04a3f3..4145896c635c 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -11,6 +11,7 @@ import { SessionStatus } from "./status" export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly cancelRun: (sessionID: SessionID) => Effect.Effect readonly ensureRunning: ( sessionID: SessionID, onInterrupt: Effect.Effect, @@ -85,6 +86,20 @@ const layer = Layer.effect( yield* existing.cancel }) + // Runner-only interrupt without cancelling background jobs. + // Exists so the Task tool fallback path can clear a stuck child prompt + // runner before re-prompting the same session, without self-cancelling + // the enclosing background job (the job's id equals the child session id). + const cancelRun = Effect.fn("SessionRunState.cancelRun")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (!existing) { + yield* status.set(sessionID, { type: "idle" }) + return + } + yield* existing.cancel + }) + const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( sessionID: SessionID, onInterrupt: Effect.Effect, @@ -104,7 +119,7 @@ const layer = Layer.effect( .pipe(Effect.catchTag("RunnerBusy", () => Effect.fail(busyError(sessionID)))) }) - return Service.of({ assertNotBusy, cancel, ensureRunning, startShell }) + return Service.of({ assertNotBusy, cancel, cancelRun, ensureRunning, startShell }) }), ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..a74fc261af46 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -257,10 +257,15 @@ export const GlobalInfo = Schema.Struct({ }).annotate({ identifier: "GlobalSession" }) export type GlobalInfo = Types.DeepMutable> +// session.slug feeds filesystem paths (see plan()); restrict to id/path-safe characters. +export const SESSION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-_]{0,63}$/ + export const CreateInput = Schema.optional( Schema.Struct({ + id: Schema.optional(SessionID), parentID: Schema.optional(SessionID), title: Schema.optional(Schema.String), + slug: Schema.optional(Schema.String), agent: Schema.optional(Schema.String), model: Schema.optional(Model), metadata: Schema.optional(Metadata), @@ -414,8 +419,10 @@ export interface Interface { readonly list: (input?: ListInput) => Effect.Effect readonly listGlobal: (input?: GlobalListInput) => Effect.Effect readonly create: (input?: { + id?: SessionID parentID?: SessionID title?: string + slug?: string agent?: string model?: Schema.Schema.Type metadata?: typeof Metadata.Type @@ -423,6 +430,7 @@ export interface Interface { workspaceID?: WorkspaceV2.ID }) => Effect.Effect readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect + readonly root: (sessionID: SessionID) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect @@ -507,11 +515,14 @@ const layer: Layer.Layer< path?: string metadata?: typeof Metadata.Type permission?: PermissionV1.Ruleset + slug?: string }) { + if (input.slug !== undefined && !SESSION_SLUG_PATTERN.test(input.slug)) + return yield* Effect.die(new Error(`Invalid session slug: "${input.slug}"`)) const ctx = yield* InstanceState.context const result: Info = { id: SessionID.descending(input.id), - slug: Slug.create(), + slug: input.slug ?? Slug.create(), version: InstallationVersion, projectID: ctx.project.id, directory: input.directory, @@ -665,8 +676,10 @@ const layer: Layer.Layer< }) const create = Effect.fn("Session.create")(function* (input?: { + id?: SessionID parentID?: SessionID title?: string + slug?: string agent?: string model?: Schema.Schema.Type metadata?: typeof Metadata.Type @@ -676,10 +689,12 @@ const layer: Layer.Layer< const ctx = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID return yield* createNext({ + id: input?.id, parentID: input?.parentID, directory: ctx.directory, path: sessionPath(ctx.worktree, ctx.directory), title: input?.title, + slug: input?.slug, agent: input?.agent, model: input?.model, metadata: input?.metadata, @@ -731,6 +746,15 @@ const layer: Layer.Layer< return session }) + const root = Effect.fn("Session.root")(function* (sessionID: SessionID) { + let current = sessionID + while (true) { + const s = yield* get(current) + if (!s.parentID) return current + current = s.parentID + } + }) + const patch = (sessionID: SessionID, info: Patch) => Effect.gen(function* () { const current = yield* get(sessionID) @@ -908,6 +932,7 @@ const layer: Layer.Layer< listGlobal, create, fork, + root, touch, get, setTitle, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d8ca640cfba9..1acfc9950bb9 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -4,19 +4,25 @@ import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { BackgroundJob } from "@/background/job" import { Session } from "@/session/session" +import { SESSION_SLUG_PATTERN } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { deriveSubagentSessionPermission } from "../agent/subagent-permissions" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Exit, Schema, Scope } from "effect" +import { Effect, Exit, Option, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { PositiveInt } from "@opencode-ai/core/schema" +import { createHash } from "crypto" export interface TaskPromptOps { cancel(sessionID: SessionID): Effect.Effect + cancelRun(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } @@ -40,15 +46,50 @@ const BACKGROUND_UPDATED = [ "Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.", ].join("\n") +function isSlug(taskId: string): boolean { + return !taskId.startsWith("ses_") +} + +function deriveSlugSessionID(slug: string, rootID: SessionID): SessionID { + // The 12-hex root hash namespaces the slug per session tree so different roots + // can reuse the same slug; within a tree the slug itself makes the ID unique. + const hash = createHash("sha256").update(rootID).digest("hex").slice(0, 12) + return SessionID.descending(`ses_${hash}_${slug}`) +} + const BaseParameterFields = { description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }), prompt: Schema.String.annotate({ description: "The task for the agent to perform" }), subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }), + model: Schema.optional(Schema.String).annotate({ + description: + "Override the model for this subagent. Format: provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o). Takes precedence over the agent's configured model.", + }), + variant: Schema.optional(Schema.String).annotate({ + description: + 'Model variant for this dispatch (e.g. "thinking", "high", "none"). Variants are model-specific reasoning/effort presets; an unknown variant is ignored. Takes precedence over the parent turn\'s variant.', + }), task_id: Schema.optional(Schema.String).annotate({ description: - "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + 'A human-readable slug (e.g. "explore-auth") to create or resume a named task session within this root session. If the slug has not been used yet, a new task is created with that identifier and the child session adopts the slug as its display handle. If it already exists, the existing session is resumed. Also accepts full "ses_..." session IDs to resume a specific session directly.', + }), + resume: Schema.optional(Schema.Boolean).annotate({ + description: + "Explicit consent to resume an existing idle task session named by task_id. Required when task_id refers to a session with no currently-running background job. A live background task still accepts task_id updates without this flag.", }), command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), + timeout: Schema.optional(PositiveInt).annotate({ + description: + "Maximum time in milliseconds for the subagent attempt. On expiry the attempt is interrupted; if fallback_model is set, the task is retried once on it, otherwise the task fails.", + }), + fallback_model: Schema.optional(Schema.String).annotate({ + description: + "Model to retry on once (provider/model format) if the primary attempt times out or fails. Requires the model_override permission.", + }), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)).annotate({ + description: + "Opaque structured metadata stored on the child task session (visible to plugins, events, and session queries). Not shown to the subagent. On resume, keys are shallow-merged into the existing metadata.", + }), } const BaseParameters = Schema.Struct(BaseParameterFields) @@ -78,6 +119,19 @@ function renderOutput(input: { ].join("\n") } +function parseModelOverride(model: string): Effect.Effect<{ modelID: ModelV2.ID; providerID: ProviderV2.ID }, Error> { + const slash = model.indexOf("/") + if (slash <= 0 || slash === model.length - 1) { + return Effect.fail( + new Error(`Invalid model format: "${model}". Expected provider/model (e.g. anthropic/claude-sonnet-4)`), + ) + } + return Effect.succeed({ + providerID: ProviderV2.ID.make(model.slice(0, slash)), + modelID: ModelV2.ID.make(model.slice(slash + 1)), + }) +} + export const TaskTool = Tool.define( id, Effect.gen(function* () { @@ -116,6 +170,26 @@ export const TaskTool = Tool.define( ) } + const modelOverride = params.model + const overrideModel = modelOverride === undefined ? undefined : yield* parseModelOverride(modelOverride) + const fallbackModel = + params.fallback_model === undefined ? undefined : yield* parseModelOverride(params.fallback_model) + + const overridePatterns = [modelOverride, params.fallback_model].filter((x): x is string => x !== undefined) + if (overridePatterns.length > 0) { + yield* ctx.ask({ + permission: "model_override", + patterns: overridePatterns, + always: overridePatterns, + metadata: { + description: params.description, + subagent_type: params.subagent_type, + ...(modelOverride ? { model: modelOverride } : {}), + ...(params.fallback_model ? { fallback_model: params.fallback_model } : {}), + }, + }) + } + if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ permission: id, @@ -133,9 +207,51 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } - const session = params.task_id - ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) - : undefined + const slugTaskId = params.task_id && isSlug(params.task_id) ? params.task_id : undefined + if (slugTaskId && !SESSION_SLUG_PATTERN.test(slugTaskId)) { + return yield* Effect.fail( + new Error( + `Invalid task_id slug: "${slugTaskId}". Slugs must be lowercase letters, digits, hyphens, or underscores (max 64 chars).`, + ), + ) + } + const derivedID = slugTaskId ? deriveSlugSessionID(slugTaskId, yield* sessions.root(ctx.sessionID)) : undefined + + const found = params.task_id + ? yield* sessions.get(derivedID ?? SessionID.make(params.task_id)).pipe(Effect.option) + : Option.none() + if (Option.isSome(found) && found.value.parentID !== ctx.sessionID) { + return yield* Effect.fail( + new Error( + slugTaskId + ? `task_id slug "${slugTaskId}" is already used by another session in this session tree` + : `task_id ${params.task_id} is not a child of this session`, + ), + ) + } + const session = Option.getOrUndefined(found) + const resumedModel = + session?.model !== undefined + ? { modelID: session.model.id, providerID: session.model.providerID } + : undefined + const resumedVariant = + session?.model?.variant && session.model.variant !== "default" ? session.model.variant : undefined + // Resume gate: an idle (finished) session needs explicit consent; a session with a + // RUNNING background job passes here and reaches background.extend below unchanged. + if (session && params.resume !== true) { + const job = yield* background.get(session.id) + if (job?.status !== "running") + return yield* Effect.fail( + new Error( + `task_id ${params.task_id} refers to an existing idle task session; pass resume: true to continue it, or omit task_id to start a fresh task`, + ), + ) + } + if (!session && params.resume === true) { + return yield* Effect.fail( + new Error(`resume: true was passed but task_id ${params.task_id} does not name an existing task session`), + ) + } const childPermission = deriveSubagentSessionPermission({ parentSessionPermission: parent.permission ?? [], subagent: next, @@ -156,9 +272,14 @@ export const TaskTool = Tool.define( const nextSession = session ?? (yield* sessions.create({ + id: derivedID, parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, + slug: slugTaskId, agent: next.name, + model: overrideModel + ? { id: overrideModel.modelID, providerID: overrideModel.providerID } + : undefined, permission: [ ...childPermission, ...childToolDenies.filter( @@ -169,8 +290,16 @@ export const TaskTool = Tool.define( ), ), ], + metadata: params.metadata, })) + if (session && params.metadata) { + yield* sessions.setMetadata({ + sessionID: session.id, + metadata: { ...session.metadata, ...params.metadata }, + }) + } + const msg = yield* MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }).pipe( Effect.provideService(Database.Service, database), Effect.orDie, @@ -178,7 +307,7 @@ export const TaskTool = Tool.define( if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) const variant = msg.info.variant - const model = next.model ?? { + const model = overrideModel ?? resumedModel ?? next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -197,16 +326,20 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) - const runTask = Effect.fn("TaskTool.runTask")(function* () { + const runAttempt = Effect.fn("TaskTool.runAttempt")(function* (attempt: { + modelID: ModelV2.ID + providerID: ProviderV2.ID + variant: string | undefined + }) { const parts = yield* ops.resolvePromptParts(params.prompt) const result = yield* ops.prompt({ messageID: MessageID.ascending(), sessionID: nextSession.id, model: { - modelID: model.modelID, - providerID: model.providerID, + modelID: attempt.modelID, + providerID: attempt.providerID, }, - variant: next.model ? undefined : variant, + variant: attempt.variant, agent: next.name, parts, }) @@ -224,6 +357,29 @@ export const TaskTool = Tool.define( return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) + let fallbackUsed = false + const runTask = Effect.fn("TaskTool.runTask")(function* () { + const primaryVariant = params.variant ?? resumedVariant ?? (overrideModel || resumedModel || next.model ? undefined : variant) + const attempt = (m: { modelID: ModelV2.ID; providerID: ProviderV2.ID }, v: string | undefined) => { + const eff = runAttempt({ modelID: m.modelID, providerID: m.providerID, variant: v }) + return params.timeout === undefined ? eff : eff.pipe(Effect.timeout(params.timeout)) + } + const exit = yield* Effect.exit(attempt(model, primaryVariant)) + if (Exit.isSuccess(exit)) return exit.value + // Only fall back for typed failures (timeout or genuine errors). Interrupts + // (parent abort) and defects (bugs) must propagate, not retry. + // No ops.cancel here: Effect.timeout already interrupted the ops.prompt fiber, + // and calling cancel on this same session would self-cancel the enclosing + // background job (cancelBackgroundJobs matches job.id === sessionID). + if (Exit.hasInterrupts(exit) || Exit.hasDies(exit) || fallbackModel === undefined) + return yield* Effect.failCause(exit.cause) + fallbackUsed = true + // Cancel the child session's prompt runner (not the background job) + // so the fallback prompt can start a fresh run on the same session. + yield* ops.cancelRun(nextSession.id).pipe(Effect.ignore) + return yield* attempt(fallbackModel, params.variant ?? resumedVariant) + }) + const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( state: "completed" | "error", text: string, @@ -264,6 +420,8 @@ export const TaskTool = Tool.define( ) }) + // The resume gate (above) already vetted idle sessions; a RUNNING job reaches + // this point without the resume: true flag and can be extended normally. if (yield* background.extend({ id: nextSession.id, run: runTask() })) { return { title: params.description, @@ -338,9 +496,10 @@ export const TaskTool = Tool.define( if (result?.metadata?.background === true) return backgroundResult() if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) + const displayMetadata = fallbackUsed ? { ...metadata, fallback_used: true as const } : metadata return { title: params.description, - metadata, + metadata: displayMetadata, output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), } }), diff --git a/packages/opencode/src/tool/task.txt b/packages/opencode/src/tool/task.txt index c5e412f409d9..db363ddb5869 100644 --- a/packages/opencode/src/tool/task.txt +++ b/packages/opencode/src/tool/task.txt @@ -8,12 +8,23 @@ When NOT to use the Task tool: - If you are searching for code within a specific file or set of 2-3 files, use the Read tool instead of the Task tool, to find the match more quickly - If no available agent is a good fit for the task, use other tools directly +Model selection: +- Each agent has a default model (usually inherited from the parent session). +- You can override the model by passing the `model` parameter in `provider/model` format (e.g. `anthropic/claude-sonnet-4`, `openai/gpt-4o`, `google/gemini-2.5-pro`). +- Model overrides require the `model_override` permission. By default this permission is denied. The user can allow specific models or providers in their config (e.g. `"model_override": { "anthropic/*": "allow" }`). +- Model selection precedence is `model` parameter, then the subagent's configured model, then the parent assistant message model. +- The optional `variant` parameter selects a model-specific reasoning preset for this dispatch (e.g. "thinking" to force extended thinking on models that support toggling it). Unknown variants are ignored. + +Reliability: +- `timeout` (ms) bounds a single subagent attempt; on expiry the attempt is interrupted. +- `fallback_model` (provider/model) retries the task once on the fallback when the primary attempt times out or fails. Requires the model_override permission. Parent-initiated aborts never trigger the fallback. Usage notes: 1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses 2. Once you have delegated work to an agent, do not duplicate that work yourself. Continue with non-overlapping tasks, or wait for the result. For background tasks, you will be notified automatically when the result is ready. -3. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. -4. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. +3. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session. You can also pass a human-readable slug (e.g. "explore-auth") as task_id to create or resume a named task within the current root session — if the slug has not been used yet, a new task is created; if it already exists, the existing session is resumed. +4. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). Resuming an idle (finished) task session additionally requires resume: true — without it the dispatch fails instead of silently continuing an old session. A live background task accepts follow-up prompts by task_id without the flag. When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you. 5. The agent's outputs should generally be trusted 6. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands). 7. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement. +- The optional `metadata` parameter attaches opaque structured metadata to the task session for plugins and analytics (not shown to the subagent). On resume, keys are shallow-merged. diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 97f0de281483..2397afd981d6 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4559,6 +4559,25 @@ describe("ProviderTransform.variants", () => { high: { reasoningEffort: "high" }, }) }) + + test("deepseek-v4 includes none (thinking disabled) and low/medium/high/max with reasoningEffort", () => { + const model = createMockModel({ + id: "openai-compatible/deepseek-v4-1", + providerID: "openai-compatible", + api: { + id: "deepseek-v4-1-latest", + url: "https://api.deepseek.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result).sort()).toEqual(["high", "low", "max", "medium", "none"].sort()) + expect(result.none).toEqual({ thinking: { type: "disabled" } }) + expect(result.low).toEqual({ reasoningEffort: "low" }) + expect(result.medium).toEqual({ reasoningEffort: "medium" }) + expect(result.high).toEqual({ reasoningEffort: "high" }) + expect(result.max).toEqual({ reasoningEffort: "max" }) + }) }) describe("@ai-sdk/azure", () => { diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index 51ff867ea44d..4416e9555a66 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -313,16 +313,43 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = ` "description": "A short (3-5 words) description of the task", "type": "string", }, + "fallback_model": { + "description": "Model to retry on once (provider/model format) if the primary attempt times out or fails. Requires the model_override permission.", + "type": "string", + }, + "metadata": { + "description": "Opaque structured metadata stored on the child task session (visible to plugins, events, and session queries). Not shown to the subagent. On resume, keys are shallow-merged into the existing metadata.", + "type": "object", + }, + "model": { + "description": "Override the model for this subagent. Format: provider/model (e.g. anthropic/claude-sonnet-4, openai/gpt-4o). Takes precedence over the agent's configured model.", + "type": "string", + }, "prompt": { "description": "The task for the agent to perform", "type": "string", }, + "resume": { + "description": "Explicit consent to resume an existing idle task session named by task_id. Required when task_id refers to a session with no currently-running background job. A live background task still accepts task_id updates without this flag.", + "type": "boolean", + }, "subagent_type": { "description": "The type of specialized agent to use for this task", "type": "string", }, "task_id": { - "description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)", + "description": "A human-readable slug (e.g. "explore-auth") to create or resume a named task session within this root session. If the slug has not been used yet, a new task is created with that identifier and the child session adopts the slug as its display handle. If it already exists, the existing session is resumed. Also accepts full "ses_..." session IDs to resume a specific session directly.", + "type": "string", + }, + "timeout": { + "description": "Maximum time in milliseconds for the subagent attempt. On expiry the attempt is interrupted; if fallback_model is set, the task is retried once on it, otherwise the task fails.", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer", + }, + "variant": { + "description": "Model variant for this dispatch (e.g. "thinking", "high", "none"). Variants are model-specific reasoning/effort presets; an unknown variant is ignored. Takes precedence over the parent turn's variant.", "type": "string", }, }, diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 9c540daad085..a7e515cac640 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -243,9 +243,41 @@ describe("tool parameters", () => { const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", background: true }) expect(parsed.background).toBe(true) }) + test("accepts optional model override", () => { + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", model: "openai/gpt-4o" }) + expect(parsed.model).toBe("openai/gpt-4o") + }) + test("accepts optional variant", () => { + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", variant: "thinking" }) + expect(parsed.variant).toBe("thinking") + }) + test("accepts optional metadata object", () => { + const parsed = parse(Task, { + description: "d", + prompt: "p", + subagent_type: "general", + metadata: { domain: "code-review", family: "anthropic" }, + }) + expect(parsed.metadata).toEqual({ domain: "code-review", family: "anthropic" }) + }) test("rejects missing prompt", () => { expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false) }) + test("accepts optional resume flag", () => { + const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general", task_id: "x-y", resume: true }) + expect(parsed.resume).toBe(true) + }) + test("accepts optional timeout and fallback_model", () => { + const parsed = parse(Task, { + description: "d", prompt: "p", subagent_type: "general", + timeout: 60000, fallback_model: "openai/gpt-4o", + }) + expect(parsed.timeout).toBe(60000) + expect(parsed.fallback_model).toBe("openai/gpt-4o") + }) + test("rejects non-positive timeout", () => { + expect(accepts(Task, { description: "d", prompt: "p", subagent_type: "general", timeout: 0 })).toBe(false) + }) }) describe("todo", () => { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 42f46fd35d7e..41a0fcae7d86 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -104,6 +104,7 @@ function stubOps(opts?: { }): TaskPromptOps { return { cancel: () => Effect.void, + cancelRun: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { @@ -261,6 +262,7 @@ describe("tool.task", () => { prompt: "look into the cache key path", subagent_type: "general", task_id: child.id, + resume: true, }, { sessionID: chat.id, @@ -371,6 +373,78 @@ describe("tool.task", () => { }), ) + it.instance("persists dispatch metadata on the child session", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const result = yield* def.execute( + { + description: "review code", + prompt: "review the code", + subagent_type: "general", + metadata: { domain: "code-review", score_tap: true }, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.metadata).toEqual({ domain: "code-review", score_tap: true }) + }), + ) + + it.instance("merges dispatch metadata into an existing child session on resume", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ + parentID: chat.id, + title: "resumed child", + agent: "general", + metadata: { domain: "code-review", round: 1 }, + }) + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + yield* def.execute( + { + description: "review code again", + prompt: "review again", + subagent_type: "general", + task_id: child.id, + resume: true, + metadata: { round: 2 }, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const updated = yield* sessions.get(child.id) + expect(updated.metadata).toEqual({ domain: "code-review", round: 2 }) + }), + ) + it.instance("execute asks by default and skips checks when bypassed", () => Effect.gen(function* () { const { chat, assistant } = yield* seed() @@ -430,6 +504,7 @@ describe("tool.task", () => { Effect.sync(() => { cancelled.resolve(sessionID) }), + cancelRun: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.promise(() => { @@ -652,6 +727,291 @@ describe("tool.task", () => { }, ) + it.instance( + "execute uses explicit model override before subagent and parent models", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + expect(result.metadata.model.providerID as string).toBe("anthropic") + expect(result.metadata.model.modelID as string).toBe("claude-sonnet-4") + expect((seen?.model?.providerID ?? "") as string).toBe("anthropic") + expect((seen?.model?.modelID ?? "") as string).toBe("claude-sonnet-4") + expect(calls[0]).toEqual({ + permission: "model_override", + patterns: ["anthropic/claude-sonnet-4"], + always: ["anthropic/claude-sonnet-4"], + metadata: { + description: "inspect bug", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + }) + expect(calls[1]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + { + config: { + agent: { + general: { + model: "openai/gpt-4o-mini", + }, + }, + }, + }, + ) + + it.instance("does not bypass model_override permission check when task check is bypassed", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "model_override", + patterns: ["anthropic/claude-sonnet-4"], + always: ["anthropic/claude-sonnet-4"], + metadata: { + description: "inspect bug", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + }) + expect(result.metadata.model.providerID as string).toBe("anthropic") + expect(result.metadata.model.modelID as string).toBe("claude-sonnet-4") + }), + ) + + it.instance("stops before task permission when model override permission fails", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }).pipe( + Effect.andThen( + input.permission === "model_override" + ? Effect.die(new Error("model override denied")) + : Effect.void, + ), + ), + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(calls).toEqual([ + { + permission: "model_override", + patterns: ["anthropic/claude-sonnet-4"], + always: ["anthropic/claude-sonnet-4"], + metadata: { + description: "inspect bug", + subagent_type: "general", + model: "anthropic/claude-sonnet-4", + }, + }, + ]) + }), + ) + + it.instance( + "execute uses subagent model when no explicit override is provided", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.model.providerID as string).toBe("openai") + expect(result.metadata.model.modelID as string).toBe("gpt-4o-mini") + expect((seen?.model?.providerID ?? "") as string).toBe("openai") + expect((seen?.model?.modelID ?? "") as string).toBe("gpt-4o-mini") + }), + { + config: { + agent: { + general: { + model: "openai/gpt-4o-mini", + }, + }, + }, + }, + ) + + it.instance("execute uses parent assistant model when no explicit or subagent model is provided", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.model.providerID).toBe(ref.providerID) + expect(result.metadata.model.modelID).toBe(ref.modelID) + expect(seen?.model?.providerID).toBe(ref.providerID) + expect(seen?.model?.modelID).toBe(ref.modelID) + }), + ) + + it.instance("rejects invalid model override strings before asking permissions", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + yield* Effect.forEach(["gpt-4o", "openai/"], (model) => + Effect.gen(function* () { + const calls: unknown[] = [] + const exit = yield* def + .execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + model, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain(`Invalid model format: "${model}"`) + expect(calls).toHaveLength(0) + }), + ) + }), + ) + it.instance("rejects background execution when the experiment is disabled", () => Effect.gen(function* () { const { chat, assistant } = yield* seed() @@ -695,6 +1055,7 @@ describe("tool.task", () => { let runs = 0 const promptOps: TaskPromptOps = { cancel: () => Effect.void, + cancelRun: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => { if (input.sessionID === chat.id) { @@ -1098,4 +1459,641 @@ describe("tool.task", () => { expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled") }), ) + + it.instance( + "resume prefers the child session's last-used model over the agent default", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const sessions = yield* Session.Service + const child = yield* sessions.create({ + parentID: chat.id, + title: "resumed child", + agent: "general", + model: { + providerID: ProviderV2.ID.make("anthropic"), + id: ModelV2.ID.make("claude-sonnet-4"), + }, + }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { description: "continue work", prompt: "keep going", subagent_type: "general", task_id: child.id, resume: true }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.model.providerID as string).toBe("anthropic") + expect(result.metadata.model.modelID as string).toBe("claude-sonnet-4") + expect((seen?.model?.providerID ?? "") as string).toBe("anthropic") + expect((seen?.model?.modelID ?? "") as string).toBe("claude-sonnet-4") + }), + { config: { agent: { general: { model: "openai/gpt-4o-mini" } } } }, + ) + + it.instance( + "resume with explicit model param overrides the session's last-used model", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const sessions = yield* Session.Service + const child = yield* sessions.create({ + parentID: chat.id, + title: "resumed child", + agent: "general", + model: { + providerID: ProviderV2.ID.make("anthropic"), + id: ModelV2.ID.make("claude-sonnet-4"), + }, + }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "continue work", + prompt: "keep going", + subagent_type: "general", + task_id: child.id, + resume: true, + model: "openai/gpt-4o", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.model.providerID as string).toBe("openai") + expect(result.metadata.model.modelID as string).toBe("gpt-4o") + expect((seen?.model?.providerID ?? "") as string).toBe("openai") + expect((seen?.model?.modelID ?? "") as string).toBe("gpt-4o") + }), + { config: { agent: { general: { model: "openai/gpt-4o-mini" } } } }, + ) + + it.instance( + "resume preserves the child session's last-used variant", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const sessions = yield* Session.Service + const child = yield* sessions.create({ + parentID: chat.id, + title: "resumed child", + agent: "general", + model: { + providerID: ProviderV2.ID.make("anthropic"), + id: ModelV2.ID.make("claude-sonnet-4"), + variant: "thinking", + }, + }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { description: "continue work", prompt: "keep going", subagent_type: "general", task_id: child.id, resume: true }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.variant).toBe("thinking") + }), + { config: { agent: { general: { model: "openai/gpt-4o-mini" } } } }, + ) + + it.instance( + "passes an explicit variant through to the child prompt", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) + + yield* def.execute( + { description: "think hard", prompt: "analyze", subagent_type: "general", variant: "thinking" }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.variant).toBe("thinking") + }), + ) + + it.instance( + "slug task_id creates a named child and resumes it on the second dispatch", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const sessions = yield* Session.Service + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const dispatch = (desc: string, extras?: Record) => + def.execute( + { description: desc, prompt: "do work", subagent_type: "general", task_id: "explore-auth", ...extras }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const first = yield* dispatch("create slug child", { metadata: { domain: "exploration" } }) + const second = yield* dispatch("resume slug child", { resume: true }) + + expect(first.metadata.sessionId).toBe(second.metadata.sessionId) + + const child = yield* sessions.get(first.metadata.sessionId) + expect(child.slug).toBe("explore-auth") + expect(child.parentID).toBe(chat.id) + expect(child.metadata).toEqual({ domain: "exploration" }) + }), + ) + + it.instance( + "rejects slugs with path or format hazards", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const badSlugs = ["../escape", "a/b", "UPPER CASE", ".hidden", "-lead", "x".repeat(65)] + + for (const slug of badSlugs) { + const exit = yield* def + .execute( + { description: "test", prompt: "test", subagent_type: "general", task_id: slug }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Invalid task_id slug") + } + }), + ) + + it.instance( + "rejects a slug already used by another parent in the same session tree", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const sessions = yield* Session.Service + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + // First dispatch from root succeeds + yield* def.execute( + { description: "first", prompt: "do work", subagent_type: "general", task_id: "shared-slug" }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + // Create a sibling parent session (same tree: child of root) + const sibling = yield* sessions.create({ parentID: chat.id, title: "sibling parent", agent: "general" }) + const siblingUser = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: sibling.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + const siblingAssistant: SessionV1.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: siblingUser.id, + sessionID: sibling.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + variant: "xhigh", + time: { created: Date.now() }, + } + yield* sessions.updateMessage(siblingAssistant) + + // Dispatch from sibling with same slug — should fail (different parent, same tree) + const exit = yield* def + .execute( + { description: "second", prompt: "do work", subagent_type: "general", task_id: "shared-slug" }, + { + sessionID: sibling.id, + messageID: siblingAssistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("already used by another session in this session tree") + } + }), + { config: { subagent_depth: 2 } }, + ) + + it.instance( + "rejects resuming an idle task session without resume: true", + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "done child", agent: "general" }) + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const exit = yield* def + .execute( + { description: "test", prompt: "test", subagent_type: "general", task_id: child.id }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("resume: true") + }), + ) + + it.instance( + "resumes an idle task session when resume: true is passed", + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "done child", agent: "general" }) + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const result = yield* def.execute( + { description: "test", prompt: "test", subagent_type: "general", task_id: child.id, resume: true }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.metadata.sessionId).toBe(child.id) + }), + ) + + it.instance( + "rejects resume: true when the task_id does not exist", + () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const exit = yield* def + .execute( + { + description: "test", + prompt: "test", + subagent_type: "general", + task_id: "never-used-slug", + resume: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.instance( + "rejects resuming a task_id that belongs to another session", + () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const other = yield* seed("Other root") + const foreign = yield* sessions.create({ parentID: other.chat.id, title: "foreign child", agent: "general" }) + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps() + + const exit = yield* def + .execute( + { description: "test", prompt: "test", subagent_type: "general", task_id: foreign.id }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.instance("falls back to fallback_model when the primary attempt times out", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const prompts: SessionPrompt.PromptInput[] = [] + const cancelRuns: number[] = [] + const ops: TaskPromptOps = { + cancel: () => Effect.void, + cancelRun: () => Effect.sync(() => { cancelRuns.push(1) }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text", text: template }]), + prompt: (input) => { + prompts.push(input) + if (prompts.length === 1) return Effect.never + return Effect.succeed(reply(input, "fallback says hi")) + }, + } + + const result = yield* def.execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + timeout: 2000, + fallback_model: "openai/gpt-4o", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(prompts.length).toBe(2) + expect(cancelRuns.length).toBe(1) + expect((prompts[1]?.model?.providerID ?? "") as string).toBe("openai") + expect((prompts[1]?.model?.modelID ?? "") as string).toBe("gpt-4o") + expect(result.output).toContain("fallback says hi") + expect((result.metadata as { fallback_used?: boolean }).fallback_used).toBe(true) + }), + ) + + it.instance("timeout without fallback fails the task", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ops: TaskPromptOps = { + ...stubOps(), + prompt: () => Effect.never, + } + + const exit = yield* def + .execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + timeout: 500, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.instance("gates fallback_model behind model_override permission up front", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const ops = stubOps() + + yield* def.execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + fallback_model: "openai/gpt-4o", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), + }, + ) + + const first = calls[0] as { permission: string; patterns: string[] } + expect(first.permission).toBe("model_override") + expect(first.patterns).toEqual(["openai/gpt-4o"]) + }), + ) + + it.instance("does not fall back when the primary attempt is interrupted", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const prompts: SessionPrompt.PromptInput[] = [] + const ops: TaskPromptOps = { + cancel: () => Effect.void, + cancelRun: () => Effect.void, + resolvePromptParts: (template) => Effect.succeed([{ type: "text", text: template }]), + prompt: (input) => { + prompts.push(input) + return Effect.interrupt + }, + } + + const exit = yield* def + .execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + timeout: 2000, + fallback_model: "openai/gpt-4o", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(prompts.length).toBe(1) + }), + ) + + it.instance("does not fall back on a die (defect)", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const prompts: SessionPrompt.PromptInput[] = [] + const ops: TaskPromptOps = { + cancel: () => Effect.void, + cancelRun: () => Effect.void, + resolvePromptParts: (template) => Effect.succeed([{ type: "text", text: template }]), + prompt: (input) => { + prompts.push(input) + return Effect.die(new Error("boom")) + }, + } + + const exit = yield* def + .execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + fallback_model: "openai/gpt-4o", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(prompts.length).toBe(1) + }), + ) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90c91e9158cc..da3bb5166fbb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1677,6 +1677,7 @@ export type PermissionConfig = question?: PermissionActionConfig webfetch?: PermissionActionConfig websearch?: PermissionActionConfig + model_override?: PermissionRuleConfig lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig From 1848902693f6e0769dabbcc664138aa3058ca159 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 9 Aug 2026 11:39:30 +0200 Subject: [PATCH 2/5] fix(opencode): cancel the child runner when a dispatched task fails --- packages/opencode/src/tool/task.ts | 11 +++-------- packages/opencode/test/tool/task.test.ts | 22 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1acfc9950bb9..452a857141a0 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -366,17 +366,12 @@ export const TaskTool = Tool.define( } const exit = yield* Effect.exit(attempt(model, primaryVariant)) if (Exit.isSuccess(exit)) return exit.value - // Only fall back for typed failures (timeout or genuine errors). Interrupts - // (parent abort) and defects (bugs) must propagate, not retry. - // No ops.cancel here: Effect.timeout already interrupted the ops.prompt fiber, - // and calling cancel on this same session would self-cancel the enclosing - // background job (cancelBackgroundJobs matches job.id === sessionID). + // Timeout interrupts the await, not the child runner; cancelRun stops that + // runner without canceling the enclosing background job. + yield* ops.cancelRun(nextSession.id).pipe(Effect.ignore) if (Exit.hasInterrupts(exit) || Exit.hasDies(exit) || fallbackModel === undefined) return yield* Effect.failCause(exit.cause) fallbackUsed = true - // Cancel the child session's prompt runner (not the background job) - // so the fallback prompt can start a fresh run on the same session. - yield* ops.cancelRun(nextSession.id).pipe(Effect.ignore) return yield* attempt(fallbackModel, params.variant ?? resumedVariant) }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 41a0fcae7d86..ce99db61ccf6 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1946,9 +1946,25 @@ describe("tool.task", () => { const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() + const promptSessionIDs: SessionID[] = [] + const cancelRunSessionIDs: SessionID[] = [] + const cancelRunCalled = yield* Deferred.make() const ops: TaskPromptOps = { - ...stubOps(), - prompt: () => Effect.never, + ...stubOps({ + onPrompt: (input) => { + promptSessionIDs.push(input.sessionID) + }, + }), + cancelRun: (sessionID) => + Effect.gen(function* () { + cancelRunSessionIDs.push(sessionID) + yield* Deferred.succeed(cancelRunCalled, undefined) + }), + prompt: (input) => + Effect.gen(function* () { + promptSessionIDs.push(input.sessionID) + return yield* (Effect.never as Effect.Effect) + }), } const exit = yield* def @@ -1973,6 +1989,8 @@ describe("tool.task", () => { .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) + yield* Deferred.await(cancelRunCalled).pipe(Effect.timeout("2 seconds")) + expect(cancelRunSessionIDs).toEqual([promptSessionIDs[0]]) }), ) From b8488698b651dd34757072d5438cf43ded709427 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 9 Aug 2026 12:42:35 +0200 Subject: [PATCH 3/5] fix(opencode): cancel the child runner when a fallback attempt fails --- packages/opencode/src/tool/task.ts | 12 +++- packages/opencode/test/tool/task.test.ts | 81 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 452a857141a0..0a0151e1dc57 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -364,15 +364,21 @@ export const TaskTool = Tool.define( const eff = runAttempt({ modelID: m.modelID, providerID: m.providerID, variant: v }) return params.timeout === undefined ? eff : eff.pipe(Effect.timeout(params.timeout)) } + const cancelRun = () => ops.cancelRun(nextSession.id).pipe(Effect.ignore) const exit = yield* Effect.exit(attempt(model, primaryVariant)) if (Exit.isSuccess(exit)) return exit.value - // Timeout interrupts the await, not the child runner; cancelRun stops that + // The timeout interrupts the await, not the child runner; cancelRun stops that // runner without canceling the enclosing background job. - yield* ops.cancelRun(nextSession.id).pipe(Effect.ignore) + yield* cancelRun() if (Exit.hasInterrupts(exit) || Exit.hasDies(exit) || fallbackModel === undefined) return yield* Effect.failCause(exit.cause) fallbackUsed = true - return yield* attempt(fallbackModel, params.variant ?? resumedVariant) + const fallbackExit = yield* Effect.exit(attempt(fallbackModel, params.variant ?? resumedVariant)) + if (Exit.isFailure(fallbackExit)) { + yield* cancelRun() + return yield* Effect.failCause(fallbackExit.cause) + } + return fallbackExit.value }) const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* ( diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index ce99db61ccf6..98f008e1ab94 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1941,6 +1941,87 @@ describe("tool.task", () => { }), ) + it.instance("cancels the child runner when the fallback attempt fails", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const prompts: SessionPrompt.PromptInput[] = [] + const cancelRuns: number[] = [] + const ops: TaskPromptOps = { + cancel: () => Effect.void, + cancelRun: () => Effect.sync(() => { cancelRuns.push(1) }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text", text: template }]), + prompt: (input) => { + prompts.push(input) + return Effect.never + }, + } + + const exit = yield* def + .execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + timeout: 100, + fallback_model: "openai/gpt-4o", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + expect(prompts).toHaveLength(2) + expect(cancelRuns).toHaveLength(2) + }), + ) + + it.instance("does not cancel the child runner when the task succeeds", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const cancelRuns: number[] = [] + const ops: TaskPromptOps = { + ...stubOps({ + onPrompt: () => undefined, + }), + cancelRun: () => Effect.sync(() => { cancelRuns.push(1) }), + } + + const result = yield* def.execute( + { + description: "d", + prompt: "p", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).toContain("done") + expect(cancelRuns).toHaveLength(0) + }), + ) + it.instance("timeout without fallback fails the task", () => Effect.gen(function* () { const { chat, assistant } = yield* seed() From bba912c2654fc9f4bbe3a49f2b5f15d70a9770c8 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:56:20 +0200 Subject: [PATCH 4/5] test(opencode): restore blank task error coverage --- packages/opencode/test/tool/task.test.ts | 151 +++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 98f008e1ab94..ee7c7357fdc4 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -373,6 +373,68 @@ describe("tool.task", () => { }), ) + it.instance("uses a fallback when a foreground task error string is blank", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + let error = "" + const fakeBackground: BackgroundJob.Interface = { + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + start: (input) => + Effect.succeed({ + id: input.id ?? "task", + type: input.type, + title: input.title, + status: "running", + started_at: 0, + metadata: input.metadata, + }), + extend: () => Effect.succeed(false), + wait: () => Effect.succeed({ timedOut: false, info: { id: "task", type: "task", status: "error", started_at: 0, error } }), + waitForPromotion: () => Effect.never, + promote: () => Effect.succeed(undefined), + cancel: () => Effect.succeed(undefined), + } + const task = yield* TaskTool.pipe(Effect.provideService(BackgroundJob.Service, fakeBackground)) + const def = yield* task.init() + const execute = () => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const blank = yield* execute().pipe(Effect.exit) + expect(Exit.isFailure(blank)).toBe(true) + if (Exit.isFailure(blank)) { + const failure = Cause.squash(blank.cause) + expect(failure).toBeInstanceOf(Error) + if (failure instanceof Error) expect(failure.message).toBe("Task failed") + } + + error = "real task error" + const real = yield* execute().pipe(Effect.exit) + expect(Exit.isFailure(real)).toBe(true) + if (Exit.isFailure(real)) { + const failure = Cause.squash(real.cause) + expect(failure).toBeInstanceOf(Error) + if (failure instanceof Error) expect(failure.message).toBe("real task error") + } + }), + ) + it.instance("persists dispatch metadata on the child session", () => Effect.gen(function* () { const sessions = yield* Session.Service @@ -1148,6 +1210,95 @@ describe("tool.task", () => { }), ) + background.instance("notifies the parent with a fallback when a background task error is blank", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const injected = yield* Deferred.make() + let error = "" + const fakeBackground: BackgroundJob.Interface = { + list: () => Effect.succeed([]), + get: () => Effect.succeed(undefined), + start: (input) => + Effect.succeed({ + id: input.id ?? "task", + type: input.type, + title: input.title, + status: "running", + started_at: 0, + metadata: input.metadata, + }), + extend: () => Effect.succeed(false), + wait: () => Effect.succeed({ timedOut: false, info: { id: "task", type: "task", status: "error", started_at: 0, error } }), + waitForPromotion: () => Effect.never, + promote: () => Effect.succeed(undefined), + cancel: () => Effect.succeed(undefined), + } + const task = yield* TaskTool.pipe(Effect.provideService(BackgroundJob.Service, fakeBackground)) + const def = yield* task.init() + const promptOps: TaskPromptOps = { + cancel: () => Effect.void, + cancelRun: () => Effect.void, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => { + if (input.sessionID === chat.id) return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected"))) + return Effect.succeed(reply(input, "done")) + }, + } + const execute = () => + def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + yield* execute() + const blank = yield* Deferred.await(injected) + expect(blank.parts[0]?.type).toBe("text") + if (blank.parts[0]?.type === "text") expect(blank.parts[0].text).toContain("Task failed") + + error = "real task error" + const injectedReal = yield* Deferred.make() + const realPromptOps: TaskPromptOps = { + ...promptOps, + prompt: (input) => Deferred.succeed(injectedReal, input).pipe(Effect.as(reply(input, "injected"))), + } + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + background: true, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: realPromptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const real = yield* Deferred.await(injectedReal) + expect(real.parts[0]?.type).toBe("text") + if (real.parts[0]?.type === "text") expect(real.parts[0].text).toContain("real task error") + }), + ) + background.instance("background task completion waits for running updates", () => Effect.gen(function* () { const jobs = yield* BackgroundJob.Service From e94c5099d073a938d3b816886e5d640e4a3641ef Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:59:21 +0200 Subject: [PATCH 5/5] fix(opencode): restore blank task error fallback --- packages/opencode/src/tool/task.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 0a0151e1dc57..bd8c7b9fd798 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -414,7 +414,7 @@ export const TaskTool = Tool.define( yield* background.wait({ id: jobID }).pipe( Effect.flatMap((result) => { if (result.info?.status === "completed") return inject("completed", result.info.output ?? "") - if (result.info?.status === "error") return inject("error", result.info.error ?? "") + if (result.info?.status === "error") return inject("error", result.info.error || "Task failed") return Effect.void }), Effect.forkIn(scope, { startImmediately: true }), @@ -495,7 +495,7 @@ export const TaskTool = Tool.define( background.waitForPromotion(nextSession.id), ) if (result?.metadata?.background === true) return backgroundResult() - if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) + if (result?.status === "error") return yield* Effect.fail(new Error(result.error || "Task failed")) if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) const displayMetadata = fallbackUsed ? { ...metadata, fallback_used: true as const } : metadata return {