fix(tests): iteration 7
This commit is contained in:
@@ -32,6 +32,8 @@ const impossibleSchema = JSON.stringify({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const OPEN_CODE_STRUCTURED_MODEL = 'opencode/gpt-5-nano'
|
||||||
|
|
||||||
let ctx: E2EContext
|
let ctx: E2EContext
|
||||||
|
|
||||||
async function setup(): Promise<void> {
|
async function setup(): Promise<void> {
|
||||||
@@ -94,7 +96,7 @@ async function test_all_providers_return_structured_output(): Promise<void> {
|
|||||||
await runProviderCase({
|
await runProviderCase({
|
||||||
provider: 'opencode',
|
provider: 'opencode',
|
||||||
mode: 'default',
|
mode: 'default',
|
||||||
model: 'opencode/kimi-k2.5-free',
|
model: OPEN_CODE_STRUCTURED_MODEL,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provider: "claude", model: "haiku" },
|
{ provider: "claude", model: "haiku" },
|
||||||
{ provider: "codex", model: "gpt-5.1-codex-mini" },
|
{ 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 () => {
|
runner: async () => {
|
||||||
throw new Error("failed");
|
throw new Error("failed");
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
|||||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||||
{ provider: "claude", model: "haiku" },
|
{ provider: "claude", model: "haiku" },
|
||||||
{ provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
interface SchemaValidator<T> {
|
interface SchemaValidator<T> {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { curateAgentActivity } from "../activity-curator.js";
|
|
||||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||||
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
|
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
|
||||||
import type { AgentStreamEventPayload } from "../../messages.js";
|
import type { AgentStreamEventPayload } from "../../messages.js";
|
||||||
@@ -1282,62 +1281,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
|
|||||||
240_000
|
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", () => {
|
describe("convertClaudeHistoryEntry", () => {
|
||||||
|
|||||||
@@ -542,8 +542,24 @@ export type OpenCodeEventTranslationState = {
|
|||||||
messageRoles: Map<string, OpenCodeMessageRole>;
|
messageRoles: Map<string, OpenCodeMessageRole>;
|
||||||
accumulatedUsage: AgentUsage;
|
accumulatedUsage: AgentUsage;
|
||||||
streamedPartKeys: Set<string>;
|
streamedPartKeys: Set<string>;
|
||||||
|
emittedStructuredMessageIds: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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(
|
export function translateOpenCodeEvent(
|
||||||
event: unknown,
|
event: unknown,
|
||||||
state: OpenCodeEventTranslationState
|
state: OpenCodeEventTranslationState
|
||||||
@@ -583,6 +599,23 @@ export function translateOpenCodeEvent(
|
|||||||
|
|
||||||
if (messageId && messageSessionId === state.sessionId && role) {
|
if (messageId && messageSessionId === state.sessionId && role) {
|
||||||
state.messageRoles.set(messageId, 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;
|
break;
|
||||||
}
|
}
|
||||||
@@ -759,6 +792,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
||||||
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
|
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
|
||||||
private streamedPartKeys = new Set<string>();
|
private streamedPartKeys = new Set<string>();
|
||||||
|
/** Tracks assistant messages already emitted from structured payloads. */
|
||||||
|
private emittedStructuredMessageIds = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: OpenCodeAgentConfig,
|
config: OpenCodeAgentConfig,
|
||||||
@@ -797,8 +832,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||||
const events = this.stream(prompt);
|
const events = this.stream(prompt, options);
|
||||||
const timeline: AgentTimelineItem[] = [];
|
const timeline: AgentTimelineItem[] = [];
|
||||||
let finalText = "";
|
let finalText = "";
|
||||||
let usage: AgentUsage | undefined;
|
let usage: AgentUsage | undefined;
|
||||||
@@ -826,7 +861,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async *stream(
|
async *stream(
|
||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
_options?: AgentRunOptions
|
options?: AgentRunOptions
|
||||||
): AsyncGenerator<AgentStreamEvent> {
|
): AsyncGenerator<AgentStreamEvent> {
|
||||||
this.abortController = new AbortController();
|
this.abortController = new AbortController();
|
||||||
await this.ensureMcpServersConfigured();
|
await this.ensureMcpServersConfigured();
|
||||||
@@ -842,6 +877,14 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
parts,
|
parts,
|
||||||
|
...(options?.outputSchema
|
||||||
|
? {
|
||||||
|
format: {
|
||||||
|
type: "json_schema" as const,
|
||||||
|
schema: options.outputSchema as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
@@ -927,6 +970,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (role === "assistant") {
|
} else if (role === "assistant") {
|
||||||
|
let emittedAssistantText = false;
|
||||||
// Process each part
|
// Process each part
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const partType = (part as { type?: string }).type;
|
const partType = (part as { type?: string }).type;
|
||||||
@@ -934,6 +978,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
if (partType === "text") {
|
if (partType === "text") {
|
||||||
const text = (part as { text?: string }).text;
|
const text = (part as { text?: string }).text;
|
||||||
if (text) {
|
if (text) {
|
||||||
|
emittedAssistantText = true;
|
||||||
yield {
|
yield {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "opencode",
|
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,
|
messageRoles: this.messageRoles,
|
||||||
accumulatedUsage: this.accumulatedUsage,
|
accumulatedUsage: this.accumulatedUsage,
|
||||||
streamedPartKeys: this.streamedPartKeys,
|
streamedPartKeys: this.streamedPartKeys,
|
||||||
|
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const translatedEvent of translated) {
|
for (const translatedEvent of translated) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
|
|||||||
messageRoles: new Map(),
|
messageRoles: new Map(),
|
||||||
accumulatedUsage: {},
|
accumulatedUsage: {},
|
||||||
streamedPartKeys: new Set(),
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user