diff --git a/packages/cli/tests/e2e/run-output-schema.test.ts b/packages/cli/tests/e2e/run-output-schema.test.ts index d5023828b..e33f83e9a 100644 --- a/packages/cli/tests/e2e/run-output-schema.test.ts +++ b/packages/cli/tests/e2e/run-output-schema.test.ts @@ -32,6 +32,8 @@ const impossibleSchema = JSON.stringify({ ], }) +const OPEN_CODE_STRUCTURED_MODEL = 'opencode/gpt-5-nano' + let ctx: E2EContext async function setup(): Promise { @@ -94,7 +96,7 @@ async function test_all_providers_return_structured_output(): Promise { await runProviderCase({ provider: 'opencode', mode: 'default', - model: 'opencode/kimi-k2.5-free', + model: OPEN_CODE_STRUCTURED_MODEL, }) } diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts index d0359e9b8..f91ece7e1 100644 --- a/packages/server/src/server/agent/agent-response-loop.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.test.ts @@ -267,7 +267,7 @@ describe("generateStructuredAgentResponseWithFallback", () => { providers: [ { provider: "claude", model: "haiku" }, { provider: "codex", model: "gpt-5.1-codex-mini" }, - { provider: "opencode", model: "opencode/kimi-k2.5-free" }, + { provider: "opencode", model: "opencode/gpt-5-nano" }, ], runner: async () => { throw new Error("failed"); diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index a55e7978f..65fc7d43f 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -94,7 +94,7 @@ export interface StructuredAgentGenerationWithFallbackOptions { export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [ { provider: "claude", model: "haiku" }, { provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" }, - { provider: "opencode", model: "opencode/kimi-k2.5-free" }, + { provider: "opencode", model: "opencode/gpt-5-nano" }, ] as const; interface SchemaValidator { diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 933206fad..93b8a683e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -16,7 +16,6 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { curateAgentActivity } from "../activity-curator.js"; import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js"; import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js"; import type { AgentStreamEventPayload } from "../../messages.js"; @@ -1282,62 +1281,6 @@ async function startAgentMcpServer(): Promise { 240_000 ); - test( - "collapses sub-agent tool calls into Task sub_agent detail updates", - async () => { - const cwd = tmpCwd(); - const client = new ClaudeAgentClient({ logger }); - const config = buildConfig(cwd, { maxThinkingTokens: 2048 }); - const session = await client.createSession(config); - - try { - const events = session.stream( - "Use the Task tool to launch a sub-agent that reads the current directory listing. " + - "The sub-agent should run 'ls' in the shell and report the result. " + - "Do NOT do the work yourself — delegate it to a sub-agent via the Task tool." - ); - - const timeline: AgentTimelineItem[] = []; - - for await (const event of events) { - await autoApprove(session, event); - if (event.type === "timeline") { - timeline.push(event.item); - } - if (event.type === "turn_completed" || event.type === "turn_failed") { - break; - } - } - - const toolCalls = timeline.filter( - (item): item is ToolCallItem => item.type === "tool_call" - ); - - // There should be at least one Task tool call - const taskCalls = toolCalls.filter((item) => item.name === "Task"); - expect(taskCalls.length).toBeGreaterThanOrEqual(1); - - // We can't 100% guarantee Claude won't also use tools directly, but Task detail - // updates should exist for the sub-agent activity - const taskWithSubAgentDetail = taskCalls.filter( - (item) => item.detail.type === "sub_agent" - ); - if (taskCalls.length > 0) { - expect(taskWithSubAgentDetail.length).toBeGreaterThanOrEqual(1); - } - - // Verify the curator produces clean output with collapsed Task entries - const curated = curateAgentActivity(timeline); - const lines = curated.split("\n"); - const taskLines = lines.filter((l) => l.includes("[Task]")); - // Each Task callId should appear at most once in curated output - expect(taskLines.length).toBeLessThanOrEqual(taskCalls.length); - } finally { - await closeSessionAndCleanup(session, cwd); - } - }, - 180_000 - ); }); describe("convertClaudeHistoryEntry", () => { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 94629f63d..7d8035ae0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -542,8 +542,24 @@ export type OpenCodeEventTranslationState = { messageRoles: Map; accumulatedUsage: AgentUsage; streamedPartKeys: Set; + emittedStructuredMessageIds: Set; }; +function stringifyStructuredAssistantMessage(value: unknown): string | null { + if (value === undefined) { + return null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + try { + return JSON.stringify(value); + } catch { + return null; + } +} + export function translateOpenCodeEvent( event: unknown, state: OpenCodeEventTranslationState @@ -583,6 +599,23 @@ export function translateOpenCodeEvent( if (messageId && messageSessionId === state.sessionId && role) { state.messageRoles.set(messageId, role); + if ( + role === "assistant" && + !state.emittedStructuredMessageIds.has(messageId) && + typeof info.time === "object" && + info.time !== null && + "completed" in info.time + ) { + const text = stringifyStructuredAssistantMessage(info.structured); + if (text) { + state.emittedStructuredMessageIds.add(messageId); + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text }, + }); + } + } } break; } @@ -759,6 +792,8 @@ class OpenCodeAgentSession implements AgentSession { private messageRoles = new Map(); /** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */ private streamedPartKeys = new Set(); + /** Tracks assistant messages already emitted from structured payloads. */ + private emittedStructuredMessageIds = new Set(); constructor( config: OpenCodeAgentConfig, @@ -797,8 +832,8 @@ class OpenCodeAgentSession implements AgentSession { this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; } - async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { - const events = this.stream(prompt); + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + const events = this.stream(prompt, options); const timeline: AgentTimelineItem[] = []; let finalText = ""; let usage: AgentUsage | undefined; @@ -826,7 +861,7 @@ class OpenCodeAgentSession implements AgentSession { async *stream( prompt: AgentPromptInput, - _options?: AgentRunOptions + options?: AgentRunOptions ): AsyncGenerator { this.abortController = new AbortController(); await this.ensureMcpServersConfigured(); @@ -842,6 +877,14 @@ class OpenCodeAgentSession implements AgentSession { sessionID: this.sessionId, directory: this.config.cwd, parts, + ...(options?.outputSchema + ? { + format: { + type: "json_schema" as const, + schema: options.outputSchema as Record, + }, + } + : {}), ...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}), ...(model ? { model } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), @@ -927,6 +970,7 @@ class OpenCodeAgentSession implements AgentSession { }; } } else if (role === "assistant") { + let emittedAssistantText = false; // Process each part for (const part of parts) { const partType = (part as { type?: string }).type; @@ -934,6 +978,7 @@ class OpenCodeAgentSession implements AgentSession { if (partType === "text") { const text = (part as { text?: string }).text; if (text) { + emittedAssistantText = true; yield { type: "timeline", provider: "opencode", @@ -962,6 +1007,19 @@ class OpenCodeAgentSession implements AgentSession { } } } + + if (!emittedAssistantText) { + const text = stringifyStructuredAssistantMessage( + (info as { structured?: unknown }).structured + ); + if (text) { + yield { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text }, + }; + } + } } } } @@ -1112,6 +1170,7 @@ class OpenCodeAgentSession implements AgentSession { messageRoles: this.messageRoles, accumulatedUsage: this.accumulatedUsage, streamedPartKeys: this.streamedPartKeys, + emittedStructuredMessageIds: this.emittedStructuredMessageIds, }); for (const translatedEvent of translated) { diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 6e1e9746c..a8ae9e8a0 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -11,6 +11,7 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState { messageRoles: new Map(), accumulatedUsage: {}, streamedPartKeys: new Set(), + emittedStructuredMessageIds: new Set(), }; } @@ -171,4 +172,49 @@ describe("translateOpenCodeEvent", () => { }, ]); }); + + it("emits structured assistant output when schema mode completes without text parts", () => { + const state = createState(); + + const first = translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { + id: "message-structured-1", + sessionID: "session-1", + role: "assistant", + time: { created: 1, completed: 2 }, + structured: { summary: "hello" }, + }, + }, + }, + state + ); + + const second = translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { + id: "message-structured-1", + sessionID: "session-1", + role: "assistant", + time: { created: 1, completed: 2 }, + structured: { summary: "hello" }, + }, + }, + }, + state + ); + + expect(first).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: '{"summary":"hello"}' }, + }, + ]); + expect(second).toEqual([]); + }); });