From c644643aefb0cf891514e905f7e21b4b5bed204f Mon Sep 17 00:00:00 2001 From: ismeth Date: Fri, 27 Feb 2026 15:22:03 +0100 Subject: [PATCH] feat(background-agent): integrate file writing in completion path and remove dead council code - Add _isCompleting guard and writeTaskOutput call before status flip - Propagate writeOutputToFile from LaunchInput to BackgroundTask - Delete council-result-format.ts and tests (no longer needed) - 38/38 tests passing --- src/features/background-agent/manager.ts | 11 + src/features/background-agent/spawner.ts | 1 + .../council-result-format.test.ts | 248 ------------------ .../background-task/council-result-format.ts | 82 ------ 4 files changed, 12 insertions(+), 330 deletions(-) delete mode 100644 src/tools/background-task/council-result-format.test.ts delete mode 100644 src/tools/background-task/council-result-format.ts diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 43d0a624d..5ed8b0970 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -80,6 +80,7 @@ import { type SubagentSpawnContext, } from "./subagent-spawn-limits" +import { writeTaskOutput } from "./task-output-writer" type OpencodeClient = PluginInput["client"] @@ -1690,6 +1691,16 @@ export class BackgroundManager { return false } + // Prevent concurrent re-entry during async file write + if (task._isCompleting) return false + task._isCompleting = true + + // Write output to file if requested (before status flip) + if (task.writeOutputToFile) { + const filePath = await writeTaskOutput(task, this.client) + if (filePath) task.outputFilePath = filePath + } + // Atomically mark as completed to prevent race conditions task.status = "completed" task.completedAt = new Date() diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index 675aeb5d9..3cf9ab662 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -64,6 +64,7 @@ export function createTask(input: LaunchInput): BackgroundTask { parentModel: input.parentModel, parentAgent: input.parentAgent, model: input.model, + writeOutputToFile: input.writeOutputToFile, } } diff --git a/src/tools/background-task/council-result-format.test.ts b/src/tools/background-task/council-result-format.test.ts deleted file mode 100644 index 1dd791e35..000000000 --- a/src/tools/background-task/council-result-format.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { describe, expect, it } from "bun:test" -import type { BackgroundTask } from "../../features/background-agent" -import type { BackgroundOutputClient } from "./clients" -import { formatCouncilTaskResult, isCouncilTask } from "./council-result-format" - -function createMockClient( - messages: Array<{ role: string; parts: Array<{ type: string; text: string }> }>, -): BackgroundOutputClient { - return { - session: { - messages: async () => - messages.map((m, i) => ({ - id: `msg_${i}`, - info: { role: m.role, time: new Date(Date.now() + i * 1000).toISOString() }, - parts: m.parts, - })), - }, - } -} - -function createMockTask(overrides: Partial = {}): BackgroundTask { - return { - id: "test-task", - parentSessionID: "parent-session", - parentMessageID: "parent-message", - description: "Test task", - prompt: "test prompt", - agent: "Council: Test", - status: "completed", - sessionID: "test-session", - ...overrides, - } as BackgroundTask -} - -describe("formatCouncilTaskResult", () => { - describe("#given a message with complete COUNCIL_MEMBER_RESPONSE tags", () => { - it("#then returns has_response true and response_complete true", async () => { - const client = createMockClient([ - { - role: "assistant", - parts: [{ type: "text", text: "analysis here" }], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: true, - response_complete: true, - result: "analysis here", - session_id: "test-session", - }) - }) - }) - - describe("#given a message with incomplete tags (no closing tag)", () => { - it("#then returns has_response true and response_complete false", async () => { - const client = createMockClient([ - { - role: "assistant", - parts: [{ type: "text", text: "partial analysis" }], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: true, - response_complete: false, - result: "partial analysis", - session_id: "test-session", - }) - }) - }) - - describe("#given a message with no COUNCIL_MEMBER_RESPONSE tags", () => { - it("#then returns has_response false and null result", async () => { - const client = createMockClient([ - { - role: "assistant", - parts: [{ type: "text", text: "Just some plain text without any tags." }], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: false, - response_complete: false, - result: null, - session_id: "test-session", - }) - }) - }) - - describe("#given multiple complete COUNCIL_MEMBER_RESPONSE blocks", () => { - it("#then returns content from the last complete block", async () => { - const client = createMockClient([ - { - role: "assistant", - parts: [ - { - type: "text", - text: "first analysis\nSome interim text\nfinal analysis", - }, - ], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: true, - response_complete: true, - result: "final analysis", - session_id: "test-session", - }) - }) - }) - - describe("#given empty content inside COUNCIL_MEMBER_RESPONSE tags", () => { - it("#then returns has_response true with empty string result", async () => { - const client = createMockClient([ - { - role: "assistant", - parts: [{ type: "text", text: "" }], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: true, - response_complete: true, - result: "", - session_id: "test-session", - }) - }) - }) - - describe("#given a task with no sessionID", () => { - it("#then returns has_response false and null session_id", async () => { - const client = createMockClient([]) - const task = createMockTask({ sessionID: undefined }) - const result = await formatCouncilTaskResult(task, client) - - expect(result).toEqual({ - has_response: false, - response_complete: false, - result: null, - session_id: null, - }) - }) - }) - - describe("#given exploration text before COUNCIL_MEMBER_RESPONSE tags", () => { - it("#then returns only the tagged content", async () => { - const client = createMockClient([ - { - role: "assistant", - parts: [ - { type: "text", text: "Let me explore the codebase first..." }, - { type: "text", text: "Found some interesting patterns." }, - ], - }, - { - role: "assistant", - parts: [ - { - type: "text", - text: "After analysis:\nthe actual council response", - }, - ], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: true, - response_complete: true, - result: "the actual council response", - session_id: "test-session", - }) - }) - }) - - describe("#given no assistant messages (only user messages)", () => { - it("#then returns has_response false", async () => { - const client = createMockClient([ - { - role: "user", - parts: [{ type: "text", text: "user text" }], - }, - ]) - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: false, - response_complete: false, - result: null, - session_id: "test-session", - }) - }) - }) - - describe("#given an error response from the session client", () => { - it("#then returns has_response false with session_id", async () => { - const client: BackgroundOutputClient = { - session: { - messages: async () => ({ data: undefined, error: "Session not found" }), - }, - } - const result = await formatCouncilTaskResult(createMockTask(), client) - - expect(result).toEqual({ - has_response: false, - response_complete: false, - result: null, - session_id: "test-session", - }) - }) - }) -}) - -describe("isCouncilTask", () => { - describe("#given a task with agent starting with 'Council: '", () => { - it("#then returns true for 'Council: Opus'", () => { - expect(isCouncilTask(createMockTask({ agent: "Council: Opus" }))).toBe(true) - }) - - it("#then returns true for 'Council: Gemini'", () => { - expect(isCouncilTask(createMockTask({ agent: "Council: Gemini" }))).toBe(true) - }) - }) - - describe("#given a task with a non-council agent", () => { - it("#then returns false for 'explore'", () => { - expect(isCouncilTask(createMockTask({ agent: "explore" }))).toBe(false) - }) - }) - - describe("#given a task with undefined agent", () => { - it("#then returns false", () => { - expect(isCouncilTask(createMockTask({ agent: undefined as unknown as string }))).toBe(false) - }) - }) - - describe("#given a task with lowercase 'council'", () => { - it("#then returns false (prefix is case-sensitive)", () => { - expect(isCouncilTask(createMockTask({ agent: "council" }))).toBe(false) - }) - }) -}) diff --git a/src/tools/background-task/council-result-format.ts b/src/tools/background-task/council-result-format.ts deleted file mode 100644 index 262793dd8..000000000 --- a/src/tools/background-task/council-result-format.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { BackgroundTask } from "../../features/background-agent" -import { COUNCIL_MEMBER_KEY_PREFIX } from "../../agents/builtin-agents/council-member-agents" -import type { BackgroundOutputClient } from "./clients" -import { extractMessages, getErrorMessage } from "./session-messages" - -const OPENING_TAG = "" -const CLOSING_TAG = "" - -export interface CouncilTaskResult { - has_response: boolean - response_complete: boolean - result: string | null - session_id: string | null -} - -export function isCouncilTask(task: BackgroundTask): boolean { - return task.agent?.startsWith(COUNCIL_MEMBER_KEY_PREFIX) ?? false -} - -function getTimeString(value: unknown): string { - return typeof value === "string" ? value : "" -} - -function extractCouncilResponse(fullText: string): CouncilTaskResult & { session_id: null } { - const lastOpenIdx = fullText.lastIndexOf(OPENING_TAG) - if (lastOpenIdx === -1) { - return { has_response: false, response_complete: false, result: null, session_id: null } - } - - const contentStart = lastOpenIdx + OPENING_TAG.length - const closingAfterLastOpen = fullText.indexOf(CLOSING_TAG, contentStart) - - if (closingAfterLastOpen === -1) { - const partial = fullText.slice(contentStart).trim() - return { has_response: true, response_complete: false, result: partial || null, session_id: null } - } - - const content = fullText.slice(contentStart, closingAfterLastOpen).trim() - return { has_response: true, response_complete: true, result: content, session_id: null } -} - -export async function formatCouncilTaskResult( - task: BackgroundTask, - client: BackgroundOutputClient, -): Promise { - if (!task.sessionID) { - return { has_response: false, response_complete: false, result: null, session_id: null } - } - - const messagesResult = await client.session.messages({ path: { id: task.sessionID } }) - - const errorMessage = getErrorMessage(messagesResult) - if (errorMessage) { - return { has_response: false, response_complete: false, result: null, session_id: task.sessionID } - } - - const messages = extractMessages(messagesResult) - if (!Array.isArray(messages) || messages.length === 0) { - return { has_response: false, response_complete: false, result: null, session_id: task.sessionID } - } - - const assistantMessages = messages.filter((m) => m.info?.role === "assistant") - const sorted = [...assistantMessages].sort((a, b) => { - const timeA = getTimeString(a.info?.time) - const timeB = getTimeString(b.info?.time) - return timeA.localeCompare(timeB) - }) - - const textParts: string[] = [] - for (const message of sorted) { - for (const part of message.parts ?? []) { - if ((part.type === "text" || part.type === "reasoning") && part.text) { - textParts.push(part.text) - } - } - } - - const fullText = textParts.join("\n\n") - const extracted = extractCouncilResponse(fullText) - - return { ...extracted, session_id: task.sessionID } -}