mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
preserve openai phase param
This commit is contained in:
committed by
Peter Steinberger
parent
da6f97a3f6
commit
cced1e0f76
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user