From 520bf9cb557f0d46815eede497a2cff64cc19560 Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Sun, 1 Feb 2026 16:57:49 +0900 Subject: [PATCH] feat: add thinking_max_chars option to background_output tool - Add thinking_max_chars?: number to BackgroundOutputOptions type - Add thinking_max_chars argument to background_output tool schema - Add formatFullSession option for controlling output format - Add 2 tests for thinking_max_chars functionality --- src/tools/background-task/tools.test.ts | 265 +++++++++++++++++++++++ src/tools/background-task/tools.ts | 273 ++++++++++++++++++++++-- src/tools/background-task/types.ts | 6 + 3 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 src/tools/background-task/tools.test.ts diff --git a/src/tools/background-task/tools.test.ts b/src/tools/background-task/tools.test.ts new file mode 100644 index 000000000..4a022487a --- /dev/null +++ b/src/tools/background-task/tools.test.ts @@ -0,0 +1,265 @@ +import { createBackgroundOutput } from "./tools" +import type { BackgroundTask } from "../../features/background-agent" +import type { ToolContext } from "@opencode-ai/plugin/tool" +import type { BackgroundOutputManager, BackgroundOutputClient } from "./tools" + +const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode" + +const mockContext: ToolContext = { + sessionID: "test-session", + messageID: "test-message", + agent: "test-agent", + directory: projectDir, + worktree: projectDir, + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, +} + +function createMockManager(task: BackgroundTask): BackgroundOutputManager { + return { + getTask: (id: string) => (id === task.id ? task : undefined), + } +} + +function createMockClient(messagesBySession: Record): BackgroundOutputClient { + const emptyMessages: BackgroundOutputMessage[] = [] + const client = { + session: { + messages: async ({ path }: { path: { id: string } }) => ({ + data: messagesBySession[path.id] ?? emptyMessages, + }), + }, + } satisfies BackgroundOutputClient + return client +} + +function createTask(overrides: Partial = {}): BackgroundTask { + return { + id: "task-1", + sessionID: "ses-1", + parentSessionID: "main-1", + parentMessageID: "msg-1", + description: "background task", + prompt: "do work", + agent: "test-agent", + status: "running", + ...overrides, + } +} + +describe("background_output full_session", () => { + test("includes thinking and tool results when enabled", async () => { + // #given + const task = createTask() + const manager = createMockManager(task) + const client = createMockClient({ + "ses-1": [ + { + id: "m1", + info: { role: "assistant", time: "2026-01-01T00:00:00Z", agent: "test" }, + parts: [ + { type: "text", text: "hello" }, + { type: "thinking", thinking: "thinking text" }, + { type: "tool_result", content: "tool output" }, + ], + }, + { + id: "m2", + info: { role: "assistant", time: "2026-01-01T00:00:01Z" }, + parts: [ + { type: "reasoning", text: "reasoning text" }, + { type: "text", text: "after" }, + ], + }, + ], + }) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ + task_id: "task-1", + full_session: true, + include_thinking: true, + include_tool_results: true, + }, mockContext) + + // #then + expect(output).toContain("thinking text") + expect(output).toContain("reasoning text") + expect(output).toContain("tool output") + }) + + test("respects since_message_id exclusive filtering", async () => { + // #given + const task = createTask() + const manager = createMockManager(task) + const client = createMockClient({ + "ses-1": [ + { + id: "m1", + info: { role: "assistant", time: "2026-01-01T00:00:00Z" }, + parts: [{ type: "text", text: "hello" }], + }, + { + id: "m2", + info: { role: "assistant", time: "2026-01-01T00:00:01Z" }, + parts: [{ type: "text", text: "after" }], + }, + ], + }) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ + task_id: "task-1", + full_session: true, + since_message_id: "m1", + }, mockContext) + + // #then + expect(output.includes("hello")).toBe(false) + expect(output).toContain("after") + }) + + test("returns error when since_message_id not found", async () => { + // #given + const task = createTask() + const manager = createMockManager(task) + const client = createMockClient({ + "ses-1": [ + { + id: "m1", + info: { role: "assistant", time: "2026-01-01T00:00:00Z" }, + parts: [{ type: "text", text: "hello" }], + }, + ], + }) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ + task_id: "task-1", + full_session: true, + since_message_id: "missing", + }, mockContext) + + // #then + expect(output).toContain("since_message_id not found") + }) + + test("caps message_limit at 100", async () => { + // #given + const task = createTask() + const manager = createMockManager(task) + const messages = Array.from({ length: 120 }, (_, index) => ({ + id: `m${index}`, + info: { + role: "assistant", + time: new Date(2026, 0, 1, 0, 0, index).toISOString(), + }, + parts: [{ type: "text", text: `message-${index}` }], + })) + const client = createMockClient({ "ses-1": messages }) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ + task_id: "task-1", + full_session: true, + message_limit: 200, + }, mockContext) + + // #then + expect(output).toContain("Returned: 100") + expect(output).toContain("Has more: true") + }) + + test("keeps legacy status output when full_session is false", async () => { + // #given + const task = createTask({ status: "running" }) + const manager = createMockManager(task) + const client = createMockClient({}) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ task_id: "task-1" }, mockContext) + + // #then + expect(output).toContain("# Task Status") + expect(output).toContain("Task ID") + }) + + test("truncates thinking content to thinking_max_chars", async () => { + // #given + const longThinking = "x".repeat(500) + const task = createTask() + const manager = createMockManager(task) + const client = createMockClient({ + "ses-1": [ + { + id: "m1", + info: { role: "assistant", time: "2026-01-01T00:00:00Z" }, + parts: [ + { type: "thinking", thinking: longThinking }, + { type: "text", text: "hello" }, + ], + }, + ], + }) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ + task_id: "task-1", + full_session: true, + include_thinking: true, + thinking_max_chars: 100, + }, mockContext) + + // #then + expect(output).toContain("[thinking] " + "x".repeat(100) + "...") + expect(output).not.toContain("x".repeat(200)) + }) + + test("uses default 2000 chars when thinking_max_chars not provided", async () => { + // #given + const longThinking = "y".repeat(2500) + const task = createTask() + const manager = createMockManager(task) + const client = createMockClient({ + "ses-1": [ + { + id: "m1", + info: { role: "assistant", time: "2026-01-01T00:00:00Z" }, + parts: [ + { type: "thinking", thinking: longThinking }, + { type: "text", text: "hello" }, + ], + }, + ], + }) + const tool = createBackgroundOutput(manager, client) + + // #when + const output = await tool.execute({ + task_id: "task-1", + full_session: true, + include_thinking: true, + }, mockContext) + + // #then + expect(output).toContain("[thinking] " + "y".repeat(2000) + "...") + expect(output).not.toContain("y".repeat(2100)) + }) +}) +type BackgroundOutputMessage = { + id?: string + info?: { role?: string; time?: string | { created?: number }; agent?: string } + parts?: Array<{ + type?: string + text?: string + thinking?: string + content?: string | Array<{ type: string; text?: string }> + }> +} diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 93ac690dd..b7c163b9b 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -1,4 +1,4 @@ -import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" +import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import type { BackgroundManager, BackgroundTask } from "../../features/background-agent" @@ -9,7 +9,50 @@ import { getSessionAgent } from "../../features/claude-code-session-state" import { log } from "../../shared/logger" import { consumeNewMessages } from "../../shared/session-cursor" -type OpencodeClient = PluginInput["client"] +type BackgroundOutputMessage = { + info?: { role?: string; time?: string | { created?: number }; agent?: string } + parts?: Array<{ + type?: string + text?: string + content?: string | Array<{ type: string; text?: string }> + name?: string + }> +} + +type BackgroundOutputMessagesResult = + | { data?: BackgroundOutputMessage[]; error?: unknown } + | BackgroundOutputMessage[] + +export type BackgroundOutputClient = { + session: { + messages: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundCancelClient = { + session: { + abort: (args: { path: { id: string } }) => Promise + } +} + +export type BackgroundOutputManager = Pick + +const MAX_MESSAGE_LIMIT = 100 +const THINKING_MAX_CHARS = 2000 + +type FullSessionMessagePart = { + type?: string + text?: string + thinking?: string + content?: string | Array<{ type?: string; text?: string }> + output?: string +} + +type FullSessionMessage = { + id?: string + info?: { role?: string; time?: string; agent?: string } + parts?: FullSessionMessagePart[] +} function getMessageDir(sessionID: string): string | null { if (!existsSync(MESSAGE_STORAGE)) return null @@ -197,30 +240,50 @@ ${promptPreview} \`\`\`${lastMessageSection}` } -async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise { +function getErrorMessage(value: BackgroundOutputMessagesResult): string | null { + if (Array.isArray(value)) return null + if (value.error === undefined || value.error === null) return null + if (typeof value.error === "string" && value.error.length > 0) return value.error + return String(value.error) +} + +function isSessionMessage(value: unknown): value is { + info?: { role?: string; time?: string } + parts?: Array<{ + type?: string + text?: string + content?: string | Array<{ type: string; text?: string }> + name?: string + }> +} { + return typeof value === "object" && value !== null +} + +function extractMessages(value: BackgroundOutputMessagesResult): BackgroundOutputMessage[] { + if (Array.isArray(value)) { + return value.filter(isSessionMessage) + } + if (Array.isArray(value.data)) { + return value.data.filter(isSessionMessage) + } + return [] +} + +async function formatTaskResult(task: BackgroundTask, client: BackgroundOutputClient): Promise { if (!task.sessionID) { return `Error: Task has no sessionID` } - const messagesResult = await client.session.messages({ + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ path: { id: task.sessionID }, }) - if (messagesResult.error) { - return `Error fetching messages: ${messagesResult.error}` + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` } - // Handle both SDK response structures: direct array or wrapped in .data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messages = ((messagesResult as any).data ?? messagesResult) as Array<{ - info?: { role?: string; time?: string } - parts?: Array<{ - type?: string - text?: string - content?: string | Array<{ type: string; text?: string }> - name?: string - }> - }> + const messages = extractMessages(messagesResult) if (!Array.isArray(messages) || messages.length === 0) { return `Task Result @@ -321,13 +384,160 @@ Session ID: ${task.sessionID} ${textContent || "(No text output)"}` } -export function createBackgroundOutput(manager: BackgroundManager, client: OpencodeClient): ToolDefinition { +function extractToolResultText(part: FullSessionMessagePart): string[] { + if (typeof part.content === "string" && part.content.length > 0) { + return [part.content] + } + + if (Array.isArray(part.content)) { + const blocks = part.content + .filter((block) => (block.type === "text" || block.type === "reasoning") && block.text) + .map((block) => block.text as string) + if (blocks.length > 0) return blocks + } + + if (part.output && part.output.length > 0) { + return [part.output] + } + + return [] +} + +async function formatFullSession( + task: BackgroundTask, + client: BackgroundOutputClient, + options: { + includeThinking: boolean + messageLimit?: number + sinceMessageId?: string + includeToolResults: boolean + thinkingMaxChars?: number + } +): Promise { + if (!task.sessionID) { + return formatTaskStatus(task) + } + + const messagesResult: BackgroundOutputMessagesResult = await client.session.messages({ + path: { id: task.sessionID }, + }) + + const errorMessage = getErrorMessage(messagesResult) + if (errorMessage) { + return `Error fetching messages: ${errorMessage}` + } + + const rawMessages = extractMessages(messagesResult) + if (!Array.isArray(rawMessages)) { + return "Error fetching messages: invalid response" + } + + const sortedMessages = [...(rawMessages as FullSessionMessage[])].sort((a, b) => { + const timeA = String(a.info?.time ?? "") + const timeB = String(b.info?.time ?? "") + return timeA.localeCompare(timeB) + }) + + let filteredMessages = sortedMessages + + if (options.sinceMessageId) { + const index = filteredMessages.findIndex((message) => message.id === options.sinceMessageId) + if (index === -1) { + return `Error: since_message_id not found: ${options.sinceMessageId}` + } + filteredMessages = filteredMessages.slice(index + 1) + } + + const includeThinking = options.includeThinking + const includeToolResults = options.includeToolResults + const thinkingMaxChars = options.thinkingMaxChars ?? THINKING_MAX_CHARS + + const normalizedMessages: FullSessionMessage[] = [] + for (const message of filteredMessages) { + const parts = (message.parts ?? []).filter((part) => { + if (part.type === "thinking" || part.type === "reasoning") { + return includeThinking + } + if (part.type === "tool_result") { + return includeToolResults + } + return part.type === "text" + }) + + if (parts.length === 0) { + continue + } + + normalizedMessages.push({ ...message, parts }) + } + + const limit = typeof options.messageLimit === "number" + ? Math.min(options.messageLimit, MAX_MESSAGE_LIMIT) + : undefined + const hasMore = limit !== undefined && normalizedMessages.length > limit + const visibleMessages = limit !== undefined + ? normalizedMessages.slice(0, limit) + : normalizedMessages + + const lines: string[] = [] + lines.push("# Full Session Output") + lines.push("") + lines.push(`Task ID: ${task.id}`) + lines.push(`Description: ${task.description}`) + lines.push(`Status: ${task.status}`) + lines.push(`Session ID: ${task.sessionID}`) + lines.push(`Total messages: ${normalizedMessages.length}`) + lines.push(`Returned: ${visibleMessages.length}`) + lines.push(`Has more: ${hasMore ? "true" : "false"}`) + lines.push("") + lines.push("## Messages") + + if (visibleMessages.length === 0) { + lines.push("") + lines.push("(No messages found)") + return lines.join("\n") + } + + for (const message of visibleMessages) { + const role = message.info?.role ?? "unknown" + const agent = message.info?.agent ? ` (${message.info.agent})` : "" + const time = formatMessageTime(message.info?.time) + const idLabel = message.id ? ` id=${message.id}` : "" + lines.push("") + lines.push(`[${role}${agent}] ${time}${idLabel}`) + + for (const part of message.parts ?? []) { + if (part.type === "text" && part.text) { + lines.push(part.text.trim()) + } else if (part.type === "thinking" && part.thinking) { + lines.push(`[thinking] ${truncateText(part.thinking, thinkingMaxChars)}`) + } else if (part.type === "reasoning" && part.text) { + lines.push(`[thinking] ${truncateText(part.text, thinkingMaxChars)}`) + } else if (part.type === "tool_result") { + const toolTexts = extractToolResultText(part) + for (const toolText of toolTexts) { + lines.push(`[tool result] ${toolText}`) + } + } + } + } + + return lines.join("\n") +} + +export function createBackgroundOutput(manager: BackgroundOutputManager, client: BackgroundOutputClient): ToolDefinition { return tool({ description: BACKGROUND_OUTPUT_DESCRIPTION, args: { task_id: tool.schema.string().describe("Task ID to get output from"), block: tool.schema.boolean().optional().describe("Wait for completion (default: false). System notifies when done, so blocking is rarely needed."), timeout: tool.schema.number().optional().describe("Max wait time in ms (default: 60000, max: 600000)"), + full_session: tool.schema.boolean().optional().describe("Return full session messages with filters (default: false)"), + include_thinking: tool.schema.boolean().optional().describe("Include thinking/reasoning parts in full_session output (default: false)"), + message_limit: tool.schema.number().optional().describe("Max messages to return (capped at 100)"), + since_message_id: tool.schema.string().optional().describe("Return messages after this message ID (exclusive)"), + include_tool_results: tool.schema.boolean().optional().describe("Include tool results in full_session output (default: false)"), + thinking_max_chars: tool.schema.number().optional().describe("Max characters for thinking content (default: 2000)"), }, async execute(args: BackgroundOutputArgs) { try { @@ -336,6 +546,16 @@ export function createBackgroundOutput(manager: BackgroundManager, client: Openc return `Task not found: ${args.task_id}` } + if (args.full_session === true) { + return await formatFullSession(task, client, { + includeThinking: args.include_thinking === true, + messageLimit: args.message_limit, + sinceMessageId: args.since_message_id, + includeToolResults: args.include_tool_results === true, + thinkingMaxChars: args.thinking_max_chars, + }) + } + const shouldBlock = args.block === true const timeoutMs = Math.min(args.timeout ?? 60000, 600000) @@ -387,7 +607,7 @@ export function createBackgroundOutput(manager: BackgroundManager, client: Openc }) } -export function createBackgroundCancel(manager: BackgroundManager, client: OpencodeClient): ToolDefinition { +export function createBackgroundCancel(manager: BackgroundManager, client: BackgroundCancelClient): ToolDefinition { return tool({ description: BACKGROUND_CANCEL_DESCRIPTION, args: { @@ -515,3 +735,18 @@ Status: ${task.status}` }, }) } +function formatMessageTime(value: unknown): string { + if (typeof value === "string") { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? value : date.toISOString() + } + if (typeof value === "object" && value !== null) { + if ("created" in value) { + const created = (value as { created?: number }).created + if (typeof created === "number") { + return new Date(created).toISOString() + } + } + } + return "Unknown time" +} diff --git a/src/tools/background-task/types.ts b/src/tools/background-task/types.ts index 1b6cf879b..12cd59649 100644 --- a/src/tools/background-task/types.ts +++ b/src/tools/background-task/types.ts @@ -8,6 +8,12 @@ export interface BackgroundOutputArgs { task_id: string block?: boolean timeout?: number + full_session?: boolean + include_thinking?: boolean + message_limit?: number + since_message_id?: string + include_tool_results?: boolean + thinking_max_chars?: number } export interface BackgroundCancelArgs {