preserve openai phase param

This commit is contained in:
Brian Yu
2026-03-11 13:50:37 -07:00
committed by Peter Steinberger
parent da6f97a3f6
commit cced1e0f76
3 changed files with 106 additions and 1 deletions

View File

@@ -37,12 +37,15 @@ export interface UsageInfo {
total_tokens: number;
}
export type OpenAIResponsesAssistantPhase = "commentary" | "final_answer";
export type OutputItem =
| {
type: "message";
id: string;
role: "assistant";
content: Array<{ type: "output_text"; text: string }>;
phase?: OpenAIResponsesAssistantPhase;
status?: "in_progress" | "completed";
}
| {
@@ -190,6 +193,7 @@ export type InputItem =
type: "message";
role: "system" | "developer" | "user" | "assistant";
content: string | ContentPart[];
phase?: OpenAIResponsesAssistantPhase;
}
| { type: "function_call"; id?: string; call_id?: string; name: string; arguments: string }
| { type: "function_call_output"; call_id: string; output: string }

View File

@@ -224,6 +224,7 @@ type FakeMessage =
| {
role: "assistant";
content: unknown[];
phase?: "commentary" | "final_answer";
stopReason: string;
api: string;
provider: string;
@@ -247,6 +248,7 @@ function userMsg(text: string): FakeMessage {
function assistantMsg(
textBlocks: string[],
toolCalls: Array<{ id: string; name: string; args: Record<string, unknown> }> = [],
phase?: "commentary" | "final_answer",
): FakeMessage {
const content: unknown[] = [];
for (const t of textBlocks) {
@@ -258,6 +260,7 @@ function assistantMsg(
return {
role: "assistant",
content,
phase,
stopReason: toolCalls.length > 0 ? "toolUse" : "stop",
api: "openai-responses",
provider: "openai",
@@ -302,6 +305,7 @@ function makeResponseObject(
id: string,
outputText?: string,
toolCallName?: string,
phase?: "commentary" | "final_answer",
): ResponseObject {
const output: ResponseObject["output"] = [];
if (outputText) {
@@ -310,6 +314,7 @@ function makeResponseObject(
id: "item_1",
role: "assistant",
content: [{ type: "output_text", text: outputText }],
phase,
});
}
if (toolCallName) {
@@ -391,6 +396,19 @@ describe("convertMessagesToInputItems", () => {
expect(items[0]).toMatchObject({ type: "message", role: "assistant", content: "Hi there." });
});
it("preserves assistant phase on replayed assistant messages", () => {
const items = convertMessagesToInputItems([
assistantMsg(["Working on it."], [], "commentary"),
] as Parameters<typeof convertMessagesToInputItems>[0]);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
type: "message",
role: "assistant",
content: "Working on it.",
phase: "commentary",
});
});
it("converts an assistant message with a tool call", () => {
const msg = assistantMsg(
["Let me run that."],
@@ -408,10 +426,29 @@ describe("convertMessagesToInputItems", () => {
call_id: "call_1",
name: "exec",
});
expect(textItem).not.toHaveProperty("phase");
const fc = fcItem as { arguments: string };
expect(JSON.parse(fc.arguments)).toEqual({ cmd: "ls" });
});
it("preserves assistant phase on commentary text before tool calls", () => {
const msg = assistantMsg(
["Let me run that."],
[{ id: "call_1", name: "exec", args: { cmd: "ls" } }],
"commentary",
);
const items = convertMessagesToInputItems([msg] as Parameters<
typeof convertMessagesToInputItems
>[0]);
const textItem = items.find((i) => i.type === "message");
expect(textItem).toMatchObject({
type: "message",
role: "assistant",
content: "Let me run that.",
phase: "commentary",
});
});
it("converts a tool result message", () => {
const items = convertMessagesToInputItems([toolResultMsg("call_1", "file.txt")] as Parameters<
typeof convertMessagesToInputItems
@@ -594,6 +631,16 @@ describe("buildAssistantMessageFromResponse", () => {
expect(msg.content).toEqual([]);
expect(msg.stopReason).toBe("stop");
});
it("preserves phase from assistant message output items", () => {
const response = makeResponseObject("resp_8", "Final answer", undefined, "final_answer");
const msg = buildAssistantMessageFromResponse(response, modelInfo) as {
phase?: string;
content: Array<{ type: string; text?: string }>;
};
expect(msg.phase).toBe("final_answer");
expect(msg.content[0]?.text).toBe("Final answer");
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -633,6 +680,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
releaseWsSession("sess-fallback");
releaseWsSession("sess-incremental");
releaseWsSession("sess-full");
releaseWsSession("sess-phase");
releaseWsSession("sess-tools");
releaseWsSession("sess-store-default");
releaseWsSession("sess-store-compat");
@@ -795,6 +843,40 @@ describe("createOpenAIWebSocketStreamFn", () => {
expect(doneEvent?.message.content[0]?.text).toBe("Hello back!");
});
it("keeps assistant phase on completed WebSocket responses", async () => {
const streamFn = createOpenAIWebSocketStreamFn("sk-test", "sess-phase");
const stream = streamFn(
modelStub as Parameters<typeof streamFn>[0],
contextStub as Parameters<typeof streamFn>[1],
);
const events: unknown[] = [];
const done = (async () => {
for await (const ev of await resolveStream(stream)) {
events.push(ev);
}
})();
await new Promise((r) => setImmediate(r));
const manager = MockManager.lastInstance!;
manager.simulateEvent({
type: "response.completed",
response: makeResponseObject("resp_phase", "Working...", "exec", "commentary"),
});
await done;
const doneEvent = events.find((e) => (e as { type?: string }).type === "done") as
| {
type: string;
reason: string;
message: { phase?: string; stopReason: string };
}
| undefined;
expect(doneEvent?.message.phase).toBe("commentary");
expect(doneEvent?.message.stopReason).toBe("toolUse");
});
it("falls back to HTTP when WebSocket connect fails (session pre-broken via flag)", async () => {
// Set the class-level flag BEFORE calling streamFn so the new instance
// fails on connect(). We patch the static default via MockManager directly.

View File

@@ -37,6 +37,7 @@ import {
type ContentPart,
type FunctionToolDefinition,
type InputItem,
type OpenAIResponsesAssistantPhase,
type OpenAIWebSocketManagerOptions,
type ResponseObject,
} from "./openai-ws-connection.js";
@@ -100,6 +101,7 @@ export function hasWsSession(sessionId: string): boolean {
// ─────────────────────────────────────────────────────────────────────────────
type AnyMessage = Message & { role: string; content: unknown };
type AssistantMessageWithPhase = AssistantMessage & { phase?: OpenAIResponsesAssistantPhase };
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") {
@@ -109,6 +111,10 @@ function toNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
function normalizeAssistantPhase(value: unknown): OpenAIResponsesAssistantPhase | undefined {
return value === "commentary" || value === "final_answer" ? value : undefined;
}
/** Convert pi-ai content (string | ContentPart[]) to plain text. */
function contentToText(content: unknown): string {
if (typeof content === "string") {
@@ -193,6 +199,7 @@ export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
}
if (m.role === "assistant") {
const assistantPhase = normalizeAssistantPhase((m as { phase?: unknown }).phase);
const content = m.content;
if (Array.isArray(content)) {
// Collect text blocks and tool calls separately
@@ -216,6 +223,7 @@ export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
type: "message",
role: "assistant",
content: textParts.join(""),
...(assistantPhase ? { phase: assistantPhase } : {}),
});
textParts.length = 0;
}
@@ -241,6 +249,7 @@ export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
type: "message",
role: "assistant",
content: textParts.join(""),
...(assistantPhase ? { phase: assistantPhase } : {}),
});
}
} else {
@@ -250,6 +259,7 @@ export function convertMessagesToInputItems(messages: Message[]): InputItem[] {
type: "message",
role: "assistant",
content: text,
...(assistantPhase ? { phase: assistantPhase } : {}),
});
}
}
@@ -289,9 +299,14 @@ export function buildAssistantMessageFromResponse(
modelInfo: { api: string; provider: string; id: string },
): AssistantMessage {
const content: (TextContent | ToolCall)[] = [];
let assistantPhase: OpenAIResponsesAssistantPhase | undefined;
for (const item of response.output ?? []) {
if (item.type === "message") {
const itemPhase = normalizeAssistantPhase(item.phase);
if (itemPhase) {
assistantPhase = itemPhase;
}
for (const part of item.content ?? []) {
if (part.type === "output_text" && part.text) {
content.push({ type: "text", text: part.text });
@@ -321,7 +336,7 @@ export function buildAssistantMessageFromResponse(
const hasToolCalls = content.some((c) => c.type === "toolCall");
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
return buildAssistantMessage({
const message = buildAssistantMessage({
model: modelInfo,
content,
stopReason,
@@ -331,6 +346,10 @@ export function buildAssistantMessageFromResponse(
totalTokens: response.usage?.total_tokens ?? 0,
}),
});
return assistantPhase
? ({ ...message, phase: assistantPhase } as AssistantMessageWithPhase)
: message;
}
// ─────────────────────────────────────────────────────────────────────────────