fix(ai): preserve OpenAI Responses assistant phase across turns closes #1819

This commit is contained in:
Mario Zechner 2026-03-05 21:12:49 +01:00
parent 3de8c48692
commit 87d71380e2
6 changed files with 48 additions and 8 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Preserved OpenAI Responses assistant `phase` metadata (`commentary`, `final_answer`) across turns by encoding `id` and `phase` in `textSignature` for session persistence and replay, with backward compatibility for legacy plain signatures ([#1819](https://github.com/badlogic/pi-mono/issues/1819)).
## [0.56.1] - 2026-03-05
## [0.56.0] - 2026-03-04

View file

@ -48,7 +48,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"chalk": "^5.6.2",
"openai": "6.10.0",
"openai": "6.26.0",
"partial-json": "^0.1.7",
"proxy-agent": "^6.5.0",
"undici": "^7.19.1",

View file

@ -20,6 +20,7 @@ import type {
Model,
StopReason,
TextContent,
TextSignatureV1,
ThinkingContent,
Tool,
ToolCall,
@ -48,6 +49,32 @@ function shortHash(str: string): string {
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
}
function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
const payload: TextSignatureV1 = { v: 1, id };
if (phase) payload.phase = phase;
return JSON.stringify(payload);
}
function parseTextSignature(
signature: string | undefined,
): { id: string; phase?: TextSignatureV1["phase"] } | undefined {
if (!signature) return undefined;
if (signature.startsWith("{")) {
try {
const parsed = JSON.parse(signature) as Partial<TextSignatureV1>;
if (parsed.v === 1 && typeof parsed.id === "string") {
if (parsed.phase === "commentary" || parsed.phase === "final_answer") {
return { id: parsed.id, phase: parsed.phase };
}
return { id: parsed.id };
}
} catch {
// Fall through to legacy plain-string handling.
}
}
return { id: signature };
}
export interface OpenAIResponsesStreamOptions {
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
applyServiceTierPricing?: (
@ -152,8 +179,9 @@ export function convertResponsesMessages<TApi extends Api>(
}
} else if (block.type === "text") {
const textBlock = block as TextContent;
const parsedSignature = parseTextSignature(textBlock.textSignature);
// OpenAI requires id to be max 64 characters
let msgId = textBlock.textSignature;
let msgId = parsedSignature?.id;
if (!msgId) {
msgId = `msg_${msgIndex}`;
} else if (msgId.length > 64) {
@ -165,6 +193,7 @@ export function convertResponsesMessages<TApi extends Api>(
content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }],
status: "completed",
id: msgId,
phase: parsedSignature?.phase,
} satisfies ResponseOutputMessage);
} else if (block.type === "toolCall") {
const toolCall = block as ToolCall;
@ -403,7 +432,7 @@ export async function processResponsesStream<TApi extends Api>(
currentBlock = null;
} else if (item.type === "message" && currentBlock?.type === "text") {
currentBlock.text = item.content.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("");
currentBlock.textSignature = item.id;
currentBlock.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
stream.push({
type: "text_end",
contentIndex: blockIndex(),

View file

@ -119,10 +119,16 @@ export type StreamFunction<TApi extends Api = Api, TOptions extends StreamOption
options?: TOptions,
) => AssistantMessageEventStream;
export interface TextSignatureV1 {
v: 1;
id: string;
phase?: "commentary" | "final_answer";
}
export interface TextContent {
type: "text";
text: string;
textSignature?: string; // e.g., for OpenAI responses, the message ID
textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)
}
export interface ThinkingContent {