Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/task-tool-empty-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Fix the task tool intermittently returning an empty result. Subagents that ran with memory context had a synthetic marker part appended after their answer, which was picked up as the final text part and surfaced as an empty `<task_result>` to the parent agent. The task tool now ignores synthetic, ignored, and empty text parts, and background jobs no longer let an empty run overwrite an earlier successful result, so resumed tasks keep their real output.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite the changeset as a user-facing release note

This text is published directly in release notes, but most of it explains internal marker-part selection and background-job overwrite behavior rather than concisely describing the user-visible fix. Reduce it to an imperative, feature-oriented statement such as “Prevent resumed subagent tasks from returning empty results.”

AGENTS.md reference: AGENTS.md:L158-L160

Useful? React with 👍 / 👎.

2 changes: 1 addition & 1 deletion packages/core/src/background-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const make = Effect.gen(function* () {
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const pending = job.pending - 1
const output =
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
Exit.isSuccess(exit) && exit.value && sequence > (job.output?.sequence ?? -1) // kilocode_change - empty outputs never clobber; only the latest non-empty result wins (#13469)
? { sequence, text: exit.value }
: job.output
if (Exit.isSuccess(exit) && pending > 0) {
Expand Down
21 changes: 21 additions & 0 deletions packages/core/test/background-job.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,27 @@ describe("BackgroundJob", () => {
}).pipe(Effect.provide(jobsLayer)),
)

// kilocode_change start - regression for #13469: an empty extended run must not clobber an earlier non-empty result
it.live("keeps the earlier non-empty output when an extended run returns empty", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as("real answer")),
})

expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("") })).toBe(true)

yield* Deferred.succeed(first, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "real answer" },
})
}).pipe(Effect.provide(jobsLayer)),
)
// kilocode_change end

it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
Expand Down
8 changes: 7 additions & 1 deletion packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,13 @@ export const TaskTool = Tool.define(
return yield* Effect.fail(new Error(`${errorMessage(result.info.error)}\n${resumeHint(nextSession.id)}`))
}
// kilocode_change end
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
// kilocode_change start - ignore synthetic/ignored/empty text parts (e.g. the memory marker) when picking the task result (#13469)
return (
result.parts
.filter((item): item is MessageV2.TextPart => item.type === "text")
.findLast((item) => !item.synthetic && !item.ignored && item.text.length > 0)?.text ?? ""
Comment on lines +278 to +281

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Extract task-result filtering into the Kilo mirror

This adds Kilo-specific result-selection logic directly to the shared upstream src/tool/task.ts, with its regression coverage likewise added to the shared test file. Extract the filtering into src/kilocode/tool/task.ts, move its coverage under test/kilocode/, and leave only a single marked call at this location so future upstream merges do not repeatedly conflict with this implementation.

AGENTS.md reference: packages/opencode/AGENTS.md:L76-L78

Useful? React with 👍 / 👎.

)
// kilocode_change end
},
Effect.ensuring(KiloTaskBackgroundProcess.finish(nextSession.id)),
) // kilocode_change - transfer inherited processes when the child run ends
Expand Down
64 changes: 64 additions & 0 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,70 @@ describe("tool.task", () => {
}),
)

// kilocode_change start - regression for #13469: a trailing synthetic empty text part (the memory marker)
// or an ignored length-warning part must not be picked as the task result
it.instance("returns the real answer when synthetic or ignored text parts trail it", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const promptOps: TaskPromptOps = {
...stubOps(),
prompt: (input) =>
Effect.sync(() => {
const rep = reply(input, "the actual answer")
const id = MessageID.ascending()
return {
...rep,
parts: [
...rep.parts,
{
id: PartID.ascending(),
messageID: id,
sessionID: input.sessionID,
type: "text",
text: "output limit hit",
ignored: true,
},
{
id: PartID.ascending(),
messageID: id,
sessionID: input.sessionID,
type: "text",
text: "",
synthetic: true,
ignored: true,
},
],
}
}),
}

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.output).toContain("the actual answer")
expect(result.output).not.toContain("output limit hit")
expect(result.output).not.toContain("<task_result></task_result>")
}),
)
// kilocode_change end

it.instance("prevents subagents from launching subagents by default", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
Expand Down
Loading