Skip to content

Commit 734e745

Browse files
committed
fix(opencode): clear the task result when a session is reused
1 parent 38b3b5e commit 734e745

4 files changed

Lines changed: 62 additions & 3 deletions

File tree

packages/core/src/session/projector.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
6060
summary_files: info.summary?.files,
6161
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
6262
metadata: info.metadata,
63-
result: info.result,
63+
result: info.result ?? null,
6464
cost: info.cost ?? 0,
6565
tokens_input: (info.tokens ?? { input: 0 }).input,
6666
tokens_output: (info.tokens ?? { output: 0 }).output,

packages/opencode/src/session/session.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export const SetMetadataInput = Schema.Struct({
296296
})
297297
export const SetResultInput = Schema.Struct({
298298
sessionID: SessionID,
299-
result: Result,
299+
result: Schema.NullOr(Result),
300300
})
301301
export const SetPermissionInput = Schema.Struct({
302302
sessionID: SessionID,
@@ -491,12 +491,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
491491

492492
export const use = serviceUse(Service)
493493

494-
export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert" | "permission"> & {
494+
export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert" | "permission" | "result"> & {
495495
time?: Partial<Info["time"]>
496496
share?: Partial<NonNullable<Info["share"]>> | null
497497
summary?: Info["summary"] | null
498498
revert?: Info["revert"] | null
499499
permission?: Info["permission"] | null
500+
result?: Info["result"] | null
500501
}
501502

502503
const layer: Layer.Layer<
@@ -762,6 +763,7 @@ const layer: Layer.Layer<
762763
summary: info.summary === null ? undefined : (info.summary ?? current.summary),
763764
revert: info.revert === null ? undefined : (info.revert ?? current.revert),
764765
permission: info.permission === null ? undefined : (info.permission ?? current.permission),
766+
result: info.result === null ? undefined : (info.result ?? current.result),
765767
} as Info
766768
yield* events.publish(SessionV1.Event.Updated, { sessionID, info: next })
767769
})

packages/opencode/src/tool/task.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,8 @@ export const TaskTool = Tool.define(
449449
// before starting (or extending) so a reused task_id doesn't inherit a
450450
// cancelled terminal record from its previous run.
451451
yield* interrupt.clear(nextSession.id)
452+
// A reused task_id must not inherit a structured result envelope from its previous run.
453+
if (session) yield* sessions.setResult({ sessionID: nextSession.id, result: null })
452454

453455
if (yield* background.extend({ id: nextSession.id, run: runTask() })) {
454456
return {

packages/opencode/test/tool/task.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SessionStatus } from "@/session/status"
1818

1919
import { Interrupt } from "../../src/session/interrupt"
2020
import { TaskTool, renderOutput, Event as TaskEventDef, type TaskPromptOps, childResultBlock } from "../../src/tool/task"
21+
import { TaskReturnTool } from "../../src/tool/task-return"
2122
import { Truncate } from "@/tool/truncate"
2223
import { ToolRegistry } from "@/tool/registry"
2324
import { RuntimeFlags } from "@/effect/runtime-flags"
@@ -276,6 +277,60 @@ describe("tool.task", () => {
276277
}),
277278
)
278279

280+
it.instance("does not replay a prior task_return result when resuming a task session", () =>
281+
Effect.gen(function* () {
282+
const events = yield* EventV2Bridge.Service
283+
const sessions = yield* Session.Service
284+
const { chat, assistant } = yield* seed()
285+
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
286+
const taskReturn = yield* TaskReturnTool
287+
const taskReturnDef = yield* taskReturn.init()
288+
const staleResult = { verdict: "OLD" }
289+
290+
yield* taskReturnDef.execute({ result: staleResult }, {
291+
sessionID: child.id,
292+
messageID: MessageID.ascending(),
293+
agent: "general",
294+
abort: new AbortController().signal,
295+
messages: [],
296+
metadata: () => Effect.void,
297+
ask: () => Effect.void,
298+
})
299+
300+
const captured = yield* Deferred.make<any>()
301+
yield* events.listen((event) => {
302+
if (event.type === TaskEventDef.Completed.type) return Deferred.succeed(captured, event)
303+
return Effect.void
304+
})
305+
306+
const tool = yield* TaskTool
307+
const def = yield* tool.init()
308+
const result = yield* def.execute(
309+
{
310+
description: "continue investigation",
311+
prompt: "continue the investigation without returning a structured result",
312+
subagent_type: "general",
313+
task_id: child.id,
314+
},
315+
{
316+
sessionID: chat.id,
317+
messageID: assistant.id,
318+
agent: "build",
319+
abort: new AbortController().signal,
320+
extra: { promptOps: stubOps({ text: "round two" }) },
321+
messages: [],
322+
metadata: () => Effect.void,
323+
ask: () => Effect.void,
324+
},
325+
)
326+
327+
expect(result.output).not.toContain(JSON.stringify(staleResult, null, 2))
328+
expect(result.output).not.toContain("<task_return>")
329+
const event = yield* Deferred.await(captured)
330+
expect(event.data.result).toBeUndefined()
331+
}),
332+
)
333+
279334
it.instance("execute asks by default and skips checks when bypassed", () =>
280335
Effect.gen(function* () {
281336
const { chat, assistant } = yield* seed()

0 commit comments

Comments
 (0)