mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Gateway/Control UI: preserve partial output on abort (#15026)
* Gateway/Control UI: preserve partial output on abort * fix: finalize abort partial handling and tests (#15026) (thanks @advaitpaliwal) --------- Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
This commit is contained in:
@@ -92,4 +92,125 @@ describe("handleChatEvent", () => {
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
});
|
||||
|
||||
it("processes aborted from own run and keeps partial assistant message", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const partialMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial reply" }],
|
||||
timestamp: 2,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Partial reply",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: partialMessage,
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toEqual([existingMessage, partialMessage]);
|
||||
});
|
||||
|
||||
it("falls back to streamed partial when aborted payload message is invalid", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Partial reply",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: "not-an-assistant-message",
|
||||
} as unknown as ChatEventPayload;
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.chatMessages[0]).toEqual(existingMessage);
|
||||
expect(state.chatMessages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial reply" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to streamed partial when aborted payload has non-assistant role", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "Partial reply",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "unexpected" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatMessages).toHaveLength(2);
|
||||
expect(state.chatMessages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial reply" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("processes aborted from own run without message and empty stream", () => {
|
||||
const existingMessage = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
timestamp: 1,
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "run-1",
|
||||
chatStream: "",
|
||||
chatStreamStartedAt: 100,
|
||||
chatMessages: [existingMessage],
|
||||
});
|
||||
const payload: ChatEventPayload = {
|
||||
runId: "run-1",
|
||||
sessionKey: "main",
|
||||
state: "aborted",
|
||||
};
|
||||
|
||||
expect(handleChatEvent(state, payload)).toBe("aborted");
|
||||
expect(state.chatRunId).toBe(null);
|
||||
expect(state.chatStream).toBe(null);
|
||||
expect(state.chatStreamStartedAt).toBe(null);
|
||||
expect(state.chatMessages).toEqual([existingMessage]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,20 @@ function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string }
|
||||
return { mimeType: match[1], content: match[2] };
|
||||
}
|
||||
|
||||
function normalizeAbortedAssistantMessage(message: unknown): Record<string, unknown> | null {
|
||||
if (!message || typeof message !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = message as Record<string, unknown>;
|
||||
if (candidate.role !== "assistant") {
|
||||
return null;
|
||||
}
|
||||
if (!("content" in candidate) || !Array.isArray(candidate.content)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
state: ChatState,
|
||||
message: string,
|
||||
@@ -198,6 +212,22 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
} else if (payload.state === "aborted") {
|
||||
const normalizedMessage = normalizeAbortedAssistantMessage(payload.message);
|
||||
if (normalizedMessage) {
|
||||
state.chatMessages = [...state.chatMessages, normalizedMessage];
|
||||
} else {
|
||||
const streamedText = state.chatStream ?? "";
|
||||
if (streamedText.trim()) {
|
||||
state.chatMessages = [
|
||||
...state.chatMessages,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: streamedText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
state.chatStream = null;
|
||||
state.chatRunId = null;
|
||||
state.chatStreamStartedAt = null;
|
||||
|
||||
Reference in New Issue
Block a user