diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b0a866c90e23..35d082e25e5e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -21,6 +21,35 @@ export interface TaskPromptOps { prompt(input: SessionPrompt.PromptInput): Effect.Effect } +type ForwardedFilePart = Extract + +function parentAttachments( + messages: SessionV1.WithParts[], + parentMessageID: MessageID, + subagent: string, +): ForwardedFilePart[] { + const parent = messages.find((message) => message.info.role === "user" && message.info.id === parentMessageID) + // Only forward attachments when the parent message explicitly targets this subagent. + if (!parent || !parent.parts.some((part) => part.type === "agent" && part.name === subagent)) return [] + + const seen = new Set() + return parent.parts.flatMap((part) => { + if (part.type !== "file") return [] + const key = [part.url, part.mime, part.filename ?? ""].join("\n") + if (seen.has(key)) return [] + seen.add(key) + return [ + { + type: "file" as const, + mime: part.mime, + filename: part.filename, + url: part.url, + source: part.source, + }, + ] + }) +} + const id = "task" const BACKGROUND_DESCRIPTION = [ "Background mode: background=true launches the subagent asynchronously and returns immediately.", @@ -162,11 +191,12 @@ export const TaskTool = Tool.define( Effect.orDie, ) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - const variant = msg.info.variant + const assistant = msg.info + const variant = assistant.variant const model = next.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, + modelID: assistant.modelID, + providerID: assistant.providerID, } const metadata = { parentSessionId: ctx.sessionID, @@ -185,6 +215,23 @@ export const TaskTool = Tool.define( const runTask = Effect.fn("TaskTool.runTask")(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) + const forwarded = parentAttachments(ctx.messages, assistant.parentID, next.name) + const promptParts = + forwarded.length === 0 + ? parts + : [ + ...parts, + ...forwarded.filter( + (candidate) => + !parts.some( + (part) => + part.type === "file" && + part.url === candidate.url && + part.mime === candidate.mime && + part.filename === candidate.filename, + ), + ), + ] const result = yield* ops.prompt({ messageID: MessageID.ascending(), sessionID: nextSession.id, @@ -194,7 +241,7 @@ export const TaskTool = Tool.define( }, variant: next.model ? undefined : variant, agent: next.name, - parts, + parts: promptParts, }) return result.parts.findLast((item) => item.type === "text")?.text ?? "" }) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 97bb7db065bd..3248661fcbd3 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -248,6 +248,62 @@ describe("tool.task", () => { }), ) + it.instance("execute forwards parent user file parts for matching agent handoff", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.parentID, + sessionID: chat.id, + type: "agent", + name: "general", + source: { + value: "@general", + start: 0, + end: 8, + }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistant.parentID, + sessionID: chat.id, + type: "file", + mime: "image/png", + filename: "image.png", + url: "data:image/png;base64,AAAA", + }) + + 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: "inspect image", + prompt: "describe this image", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: yield* sessions.messages({ sessionID: chat.id }), + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.parts).toEqual([ + { type: "text", text: "describe this image" }, + { type: "file", mime: "image/png", filename: "image.png", url: "data:image/png;base64,AAAA" }, + ]) + }), + ) + it.instance("execute asks by default and skips checks when bypassed", () => Effect.gen(function* () { const { chat, assistant } = yield* seed()