Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,35 @@ export interface TaskPromptOps {
prompt(input: SessionPrompt.PromptInput): Effect.Effect<SessionV1.WithParts>
}

type ForwardedFilePart = Extract<SessionPrompt.PromptInput["parts"][number], { type: "file" }>

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<string>()
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.",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 ?? ""
})
Expand Down
56 changes: 56 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading