Q80: turns and replies the provider cut short fail instead of passing as finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d96967cfb9
commit
0970b84336
14 files changed
+208
-37
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import type { JsonRecord, ProviderEventStreamMessage } from "../../src/main/services/provider-http";
|
||||
import { accumulateResponsesStream, parseResponsesOutput } from "../../src/main/services/provider-tool-turns/responses-format";
|
||||
import { accumulateChatStream, parseChatChoice } from "../../src/main/services/provider-tool-turns/chat-format";
|
||||
import { parseAnthropicContent } from "../../src/main/services/provider-tool-turns/anthropic-format";
|
||||
import { parseBedrockOutput } from "../../src/main/services/provider-tool-turns/bedrock-format";
|
||||
import { accumulateGeminiStream, parseGeminiResponse } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { mapAnthropicMessageStream, mapOpenAiChatCompletionStream, mapOpenAiResponseStream } from "../../src/main/services/provider-streaming";
|
||||
|
||||
async function* stream(items: JsonRecord[]): AsyncIterable<ProviderEventStreamMessage> {
|
||||
for (const data of items) yield { eventName: null, data, rawData: JSON.stringify(data) };
|
||||
}
|
||||
|
||||
async function types(events: AsyncIterable<{ type: string; textDelta: string | null }>): Promise<string[]> {
|
||||
const seen: string[] = [];
|
||||
for await (const event of events) seen.push(event.type === "error" ? `error:${event.textDelta}` : event.type);
|
||||
return seen;
|
||||
}
|
||||
|
||||
const call = (id: string) => ({ type: "function_call", call_id: id, name: "write_file", arguments: '{"path":"a' });
|
||||
|
||||
describe("a turn the provider cut short is never treated as finished", () => {
|
||||
test("Responses: incomplete status, incomplete event, and a stream with no completion all fail", async () => {
|
||||
expect(() => parseResponsesOutput({ status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, output: [call("c1")] })).toThrow("max_output_tokens");
|
||||
await expect(accumulateResponsesStream(stream([
|
||||
{ type: "response.output_item.done", item: call("c1") },
|
||||
{ type: "response.incomplete", response: { status: "incomplete", incomplete_details: { reason: "content_filter" }, output: [] } },
|
||||
]))).rejects.toThrow("content_filter");
|
||||
await expect(accumulateResponsesStream(stream([{ type: "response.output_item.done", item: call("c1") }]))).rejects.toThrow("no stop reason");
|
||||
expect(parseResponsesOutput({ status: "completed", output: [] }).toolCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("Chat: length and content_filter fail, a stream without finish_reason fails, stop and tool_calls pass", async () => {
|
||||
expect(() => parseChatChoice({ choices: [{ message: { content: "half" }, finish_reason: "length" }] })).toThrow("length");
|
||||
await expect(accumulateChatStream(stream([{ choices: [{ delta: { content: "half" } }] }]))).rejects.toThrow("no stop reason");
|
||||
await expect(accumulateChatStream(stream([{ choices: [{ delta: { content: "x" }, finish_reason: "content_filter" }] }]))).rejects.toThrow("content_filter");
|
||||
expect((await accumulateChatStream(stream([{ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }]))).text).toBe("ok");
|
||||
expect(parseChatChoice({ choices: [{ message: { content: "ok" }, finish_reason: "tool_calls" }] }).text).toBe("ok");
|
||||
});
|
||||
|
||||
test("Anthropic and Bedrock: max_tokens fails; end_turn and tool_use pass", () => {
|
||||
expect(() => parseAnthropicContent({ stop_reason: "max_tokens", content: [{ type: "tool_use", id: "t", name: "f", input: {} }] })).toThrow("max_tokens");
|
||||
expect(parseAnthropicContent({ stop_reason: "tool_use", content: [] }).toolCalls).toEqual([]);
|
||||
expect(() => parseBedrockOutput({ stopReason: "max_tokens", output: { message: { content: [] } } })).toThrow("max_tokens");
|
||||
expect(parseBedrockOutput({ stopReason: "end_turn", output: { message: { content: [{ text: "ok" }] } } }).text).toBe("ok");
|
||||
});
|
||||
|
||||
test("Gemini: MAX_TOKENS fails, a stream without finishReason fails, STOP passes", async () => {
|
||||
const parts = [{ text: "ok" }];
|
||||
expect(() => parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "MAX_TOKENS" }] })).toThrow("MAX_TOKENS");
|
||||
expect(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] }).text).toBe("ok");
|
||||
await expect(accumulateGeminiStream(stream([{ response: { candidates: [{ content: { parts } }] } }]))).rejects.toThrow("no stop reason");
|
||||
expect((await accumulateGeminiStream(stream([{ response: { candidates: [{ content: { parts }, finishReason: "STOP" }] } }]))).text).toBe("ok");
|
||||
});
|
||||
|
||||
test("streamed replies: a cut-off stop is an error, not a message_stop", async () => {
|
||||
expect(await types(mapOpenAiResponseStream(stream([{ type: "response.incomplete", response: { incomplete_details: { reason: "max_output_tokens" } } }]))))
|
||||
.toEqual([expect.stringMatching(/^error:.*max_output_tokens/)]);
|
||||
expect(await types(mapOpenAiChatCompletionStream(stream([{ choices: [{ delta: { content: "half" }, finish_reason: "length" }] }]))))
|
||||
.toEqual(["message_start", "text_delta", expect.stringMatching(/^error:.*length/)]);
|
||||
expect(await types(mapAnthropicMessageStream(stream([{ type: "message_start" }, { type: "message_delta", delta: { stop_reason: "max_tokens" } }, { type: "message_stop" }]))))
|
||||
.toEqual(["message_start", expect.stringMatching(/^error:.*max_tokens/)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("one-shot responses cut short fail instead of saving partial text", () => {
|
||||
const reply = (body: unknown) => vi.stubGlobal("fetch", async () => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }));
|
||||
const input = { authProfile: { type: "api_key" as const, apiKey: "k" }, model: "m", prompt: "p", instructions: null };
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test("OpenAI incomplete, Anthropic max_tokens and chat length all reject; finished replies return their text", async () => {
|
||||
reply({ id: "r", status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, output_text: "half" });
|
||||
await expect(providerAdapters.openai.createResponse!(input)).rejects.toThrow("max_output_tokens");
|
||||
reply({ stop_reason: "max_tokens", content: [{ type: "text", text: "half" }] });
|
||||
await expect(providerAdapters.anthropic.createResponse!(input)).rejects.toThrow("max_tokens");
|
||||
reply({ choices: [{ message: { content: "half" }, finish_reason: "length" }] });
|
||||
await expect(providerAdapters.groq.createResponse!(input)).rejects.toThrow("length");
|
||||
reply({ choices: [{ message: { content: "whole" }, finish_reason: "stop" }] });
|
||||
expect((await providerAdapters.groq.createResponse!(input)).text).toBe("whole");
|
||||
reply({ stop_reason: "end_turn", content: [{ type: "text", text: "whole" }] });
|
||||
expect((await providerAdapters.anthropic.createResponse!(input)).text).toBe("whole");
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,34 @@ describe("provider service streaming", () => {
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("a stream that ends without the provider's stop fails instead of saving the partial text", async () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
const adapters: ProviderAdapterRegistry = {
|
||||
...providerAdapters,
|
||||
openai: {
|
||||
...providerAdapters.openai,
|
||||
parseAuthProfile: (secret) => ({ type: "api_key", apiKey: secret.trim() }),
|
||||
streamResponse: async () => ({
|
||||
events: streamEvents([event("message_start", null, "created", "resp_cut"), event("text_delta", "Hal", "delta-1", "resp_cut")]),
|
||||
rateLimitState: null,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const providers = new ProviderService(connection.db, adapters, testVault);
|
||||
let orgId = "";
|
||||
let openAiProviderId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = createOrganization(organization);
|
||||
openAiProviderId = providerId(connection, orgId, "openai");
|
||||
providers.saveCredential({ orgId, providerId: openAiProviderId, secret: "sk-live-provider", model: "gpt-5.4" });
|
||||
});
|
||||
await expect(providers.streamResponse({ orgId, providerId: openAiProviderId, prompt: "Draft", instructions: null, executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND }, () => {}))
|
||||
.rejects.toThrow("no stop reason");
|
||||
expect(connection.db.prepare("select state from provider_requests where org_id = ?").get(orgId)).toEqual({ state: PROVIDER_REQUEST_STATE.FAILED });
|
||||
connection.close();
|
||||
});
|
||||
|
||||
test("cancels a provider request through the adapter response id", async () => {
|
||||
const connection = createConnection();
|
||||
const organization = new OrganizationService(connection.db);
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("openai responses encoding", () => {
|
||||
});
|
||||
|
||||
test("parses output text and function calls", () => {
|
||||
const data = { output: [
|
||||
const data = { status: "completed", output: [
|
||||
{ type: "message", content: [{ type: "output_text", text: "done" }] },
|
||||
{ type: "function_call", call_id: "c2", name: "create_task", arguments: '{"title":"x"}' },
|
||||
] };
|
||||
@@ -88,8 +88,8 @@ describe("openai chat completions encoding", () => {
|
||||
});
|
||||
|
||||
test("parses a tool call choice", () => {
|
||||
const data = { choices: [{ message: { content: "hi", tool_calls: [{ id: "c9", function: { name: "f", arguments: '{"a":1}' } }] } }] };
|
||||
expect(parseChatChoice(data)).toEqual({ text: "hi", toolCalls: [{ id: "c9", name: "f", arguments: { a: 1 } }] });
|
||||
const data = { choices: [{ message: { content: "hi", tool_calls: [{ id: "c9", function: { name: "f", arguments: '{"a":1}' } }] }, finish_reason: "tool_calls" }] };
|
||||
expect(parseChatChoice(data)).toMatchObject({ text: "hi", toolCalls: [{ id: "c9", name: "f", arguments: { a: 1 } }] });
|
||||
});
|
||||
|
||||
test("accumulates streamed content deltas and indexed tool-call fragments", async () => {
|
||||
@@ -130,7 +130,7 @@ describe("anthropic encoding", () => {
|
||||
});
|
||||
|
||||
test("parses text and tool_use blocks", () => {
|
||||
const data = { content: [{ type: "text", text: "ok" }, { type: "tool_use", id: "tu", name: "f", input: { x: 1 } }] };
|
||||
const data = { stop_reason: "tool_use", content: [{ type: "text", text: "ok" }, { type: "tool_use", id: "tu", name: "f", input: { x: 1 } }] };
|
||||
expect(parseAnthropicContent(data)).toMatchObject({ text: "ok", toolCalls: [{ id: "tu", name: "f", arguments: { x: 1 } }] });
|
||||
});
|
||||
});
|
||||
@@ -162,7 +162,7 @@ describe("antigravity (gemini) encoding", () => {
|
||||
|
||||
test("accumulates streamed candidates", async () => {
|
||||
const result = await accumulateGeminiStream(stream([
|
||||
{ response: { candidates: [{ content: { parts: [{ text: "hello" }, { functionCall: { name: "f", args: { x: 1 }, id: "g1" } }] } }] } },
|
||||
{ response: { candidates: [{ content: { parts: [{ text: "hello" }, { functionCall: { name: "f", args: { x: 1 }, id: "g1" } }] }, finishReason: "STOP" }] } },
|
||||
]));
|
||||
expect(result.text).toBe("hello");
|
||||
expect(result.toolCalls).toEqual([{ id: "g1", name: "f", arguments: { x: 1 } }]);
|
||||
@@ -183,7 +183,7 @@ describe("bedrock converse encoding", () => {
|
||||
});
|
||||
|
||||
test("parses the converse output", () => {
|
||||
const data = { output: { message: { content: [{ text: "ok" }, { toolUse: { toolUseId: "t1", name: "f", input: { x: 1 } } }] } } };
|
||||
const data = { stopReason: "tool_use", output: { message: { content: [{ text: "ok" }, { toolUse: { toolUseId: "t1", name: "f", input: { x: 1 } } }] } } };
|
||||
expect(parseBedrockOutput(data)).toMatchObject({ text: "ok", toolCalls: [{ id: "t1", name: "f", arguments: { x: 1 } }] });
|
||||
});
|
||||
});
|
||||
@@ -193,7 +193,7 @@ describe("vertex (gemini) encoding", () => {
|
||||
const body = vertexRequestBody({ model: "gemini-2.5-pro", instructions: "sys", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }] });
|
||||
expect((body.systemInstruction as JsonRecord).parts).toEqual([{ text: "sys" }]);
|
||||
expect(body.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
|
||||
const parsed = parseGeminiResponse({ candidates: [{ content: { parts: [{ text: "hi" }, { functionCall: { name: "f", args: { x: 1 }, id: "v1" } }] } }] });
|
||||
const parsed = parseGeminiResponse({ candidates: [{ content: { parts: [{ text: "hi" }, { functionCall: { name: "f", args: { x: 1 }, id: "v1" } }] }, finishReason: "STOP" }] });
|
||||
expect(parsed).toMatchObject({ text: "hi", toolCalls: [{ id: "v1", name: "f", arguments: { x: 1 } }] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("reasoning survives tool turns", () => {
|
||||
{ type: "reasoning", summary: [], encrypted_content: "ENC" },
|
||||
{ type: "function_call", call_id: "c1", name: "f", arguments: "{}" },
|
||||
]);
|
||||
const plain = parseResponsesOutput({ output: [{ type: "reasoning", id: "rs_2", summary: [] }, { type: "message", id: "msg_1", role: "assistant", content: [{ type: "output_text", text: "hi" }] }] });
|
||||
const plain = parseResponsesOutput({ status: "completed", output: [{ type: "reasoning", id: "rs_2", summary: [] }, { type: "message", id: "msg_1", role: "assistant", content: [{ type: "output_text", text: "hi" }] }] });
|
||||
expect(plain.replay?.content).toEqual([{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] }]);
|
||||
});
|
||||
|
||||
@@ -41,21 +41,21 @@ describe("reasoning survives tool turns", () => {
|
||||
{ type: "redacted_thinking", data: "OPAQUE" },
|
||||
{ type: "tool_use", id: "t1", name: "f", input: { a: 1 } },
|
||||
];
|
||||
const sent = toAnthropicMessages(after(parseAnthropicContent({ content }))) as JsonRecord[];
|
||||
const sent = toAnthropicMessages(after(parseAnthropicContent({ stop_reason: "tool_use", content }))) as JsonRecord[];
|
||||
expect(sent[1]).toEqual({ role: "assistant", content });
|
||||
});
|
||||
|
||||
test("Bedrock: reasoningContent with its signature goes back unmodified", () => {
|
||||
const content = [{ reasoningContent: { reasoningText: { text: "plan", signature: "SIG" } } }, { toolUse: { toolUseId: "t1", name: "f", input: {} } }];
|
||||
const sent = toBedrockMessages(after(parseBedrockOutput({ output: { message: { role: "assistant", content } } })));
|
||||
const sent = toBedrockMessages(after(parseBedrockOutput({ stopReason: "tool_use", output: { message: { role: "assistant", content } } })));
|
||||
expect(sent[1]).toEqual({ role: "assistant", content });
|
||||
});
|
||||
|
||||
test("Gemini: thought signatures on parts go back, streamed or not", async () => {
|
||||
const parts = [{ text: "plan", thought: true }, { functionCall: { name: "f", args: {} }, thoughtSignature: "SIG" }];
|
||||
const vertex = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts } }] })), tools: [] });
|
||||
const vertex = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts }, finishReason: "STOP" }] })), tools: [] });
|
||||
expect((vertex.contents as JsonRecord[])[1]).toEqual({ role: "model", parts });
|
||||
const streamed = await accumulateGeminiStream(stream(parts.map((part) => ({ response: { candidates: [{ content: { parts: [part] } }] } }))));
|
||||
const streamed = await accumulateGeminiStream(stream([...parts.map((part) => ({ response: { candidates: [{ content: { parts: [part] } }] } })), { response: { candidates: [{ finishReason: "STOP" }] } }]));
|
||||
expect(streamed.replay).toEqual({ format: "gemini", content: parts });
|
||||
});
|
||||
|
||||
@@ -63,22 +63,22 @@ describe("reasoning survives tool turns", () => {
|
||||
const kimi = await accumulateChatStream(stream([
|
||||
{ choices: [{ delta: { reasoning_content: "think " } }] },
|
||||
{ choices: [{ delta: { reasoning_content: "hard" } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "f", arguments: "{}" } }] } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "f", arguments: "{}" } }] }, finish_reason: "tool_calls" }] },
|
||||
]));
|
||||
expect((toChatMessages(null, after(kimi)) as JsonRecord[])[1]).toMatchObject({ role: "assistant", reasoning_content: "think hard" });
|
||||
const openrouter = await accumulateChatStream(stream([
|
||||
{ choices: [{ delta: { reasoning: "a", reasoning_details: [{ type: "reasoning.text", text: "a", index: 0 }] } }] },
|
||||
{ choices: [{ delta: { reasoning: "b", reasoning_details: [{ type: "reasoning.text", text: "b", signature: "SIG", index: 0 }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ type: "reasoning.encrypted", data: "ENC", index: 1 }] } }] },
|
||||
{ choices: [{ delta: { content: "done" } }] },
|
||||
{ choices: [{ delta: { content: "done" }, finish_reason: "stop" }] },
|
||||
]));
|
||||
expect(openrouter.replay).toEqual({ format: "chat", content: { reasoning_details: [{ type: "reasoning.text", text: "ab", signature: "SIG", index: 0 }, { type: "reasoning.encrypted", data: "ENC", index: 1 }] } });
|
||||
const azure = parseChatChoice({ choices: [{ message: { content: "x", reasoning: "why" } }] });
|
||||
const azure = parseChatChoice({ choices: [{ message: { content: "x", reasoning: "why" }, finish_reason: "stop" }] });
|
||||
expect(azure.replay).toEqual({ format: "chat", content: { reasoning: "why" } });
|
||||
});
|
||||
|
||||
test("a turn from one wire format is never silently replayed in another", () => {
|
||||
const messages = after(parseAnthropicContent({ content: [{ type: "text", text: "hi" }] }));
|
||||
const messages = after(parseAnthropicContent({ stop_reason: "end_turn", content: [{ type: "text", text: "hi" }] }));
|
||||
expect(() => toChatMessages(null, messages)).toThrow("anthropic format cannot be sent to a chat provider");
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ describe("system instructions", () => {
|
||||
test("the GitHub Copilot turn sends the agent's instructions like every other Responses turn", async () => {
|
||||
vi.stubGlobal("fetch", async (_url: string, init: RequestInit) => {
|
||||
bodies.push(JSON.parse(String(init.body)) as Record<string, unknown>);
|
||||
return new Response(JSON.stringify({ output: [{ type: "message", content: [{ type: "output_text", text: "ok" }] }] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
return new Response(JSON.stringify({ status: "completed", output: [{ type: "message", content: [{ type: "output_text", text: "ok" }] }] }), { status: 200, headers: { "content-type": "application/json" } });
|
||||
});
|
||||
await runCopilotTurn({ credential: { accessToken: "tid=1;exp=9999999999" }, model: "gpt-5.4", instructions: "You are Ada, the backend engineer.", messages: [{ role: "user", text: "hi" }], tools: [] });
|
||||
expect(bodies.at(-1)?.instructions).toBe("You are Ada, the backend engineer.");
|
||||
|
||||
Reference in new issue
Block a user