Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/app/api/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,89 @@ describe("POST /api/chat", () => {
expect(streamed).toContain("data: [DONE]");
});

it("recovers Dashboard-mirrored message tool replies when chat final is only Done", async () => {
vi.useFakeTimers();
try {
const chatHandlers: Array<(payload: unknown) => void> = [];
let historyMessages: unknown[] = [];
const chatHistory = vi.fn().mockImplementation(() => Promise.resolve({ messages: historyMessages }));
mockSelectRecoveredAssistantText.mockImplementation((params: unknown) => {
const messages = (params as { messages?: Array<{ role: string | null; content: string }> }).messages ?? [];
expect(messages.some((message) => message.role === "assistant" && message.content === "Done.")).toBe(false);
return messages.find((message) =>
message.role === "assistant" &&
message.content.includes("product-videogen")
)?.content ?? "";
});
mockGetGatewayClient.mockResolvedValueOnce({
on: vi.fn((event: string, handler: (payload: unknown) => void) => {
if (event === "*") chatHandlers.push(handler);
}),
off: vi.fn(),
chatSend: vi.fn().mockResolvedValue({ runId: "run-1" }),
chatAbort: vi.fn(() => Promise.resolve()),
chatHistory,
rpc: vi.fn().mockResolvedValue({ sessions: [] }),
});

const response = await POST(makeRequest({
messages: [{ role: "user", content: "Can you summarize the product-videogen README?" }],
agent: "main",
}));
const reader = response.body!.getReader();
await readUntilContains(reader, "\"event\":\"gateway_send_started\"");

await vi.waitFor(() => {
expect(chatHandlers).toHaveLength(1);
});
await vi.advanceTimersByTimeAsync(0);

chatHandlers[0]({
event: "agent",
stream: "tool",
sessionKey: "main",
runId: "run-1",
data: {
name: "message",
phase: "completed",
result: { status: "ok" },
},
});
await readUntilContains(reader, "\"event\":\"tool_completed\"");

historyMessages = [
{ role: "user", content: "Can you summarize the product-videogen README?" },
{
role: "assistant",
content: [{
type: "text",
text:
"The README describes product-videogen as a workflow for creating product videos from prompts and assets.",
}],
openclawMessageToolMirror: { toolName: "message" },
},
{ role: "assistant", content: "Done." },
];
chatHandlers[0]({
event: "chat",
state: "final",
sessionKey: "main",
runId: "run-1",
message: { role: "assistant", content: "Done." },
});

await vi.advanceTimersByTimeAsync(0);
const streamed = await readUntilDone(reader);
expect(streamed).toContain(
"\"choices\":[{\"delta\":{\"content\":\"The README describes product-videogen as a workflow for creating product videos from prompts and assets.\"}}]",
);
expect(streamed).not.toContain("\"choices\":[{\"delta\":{\"content\":\"Done.\"}}]");
expect(streamed).toContain("data: [DONE]");
} finally {
vi.useRealTimers();
}
});

it("keeps long-running chat streams alive with heartbeat progress", async () => {
vi.useFakeTimers();
const response = await POST(makeRequest({
Expand Down
90 changes: 83 additions & 7 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,18 +308,64 @@ function extractSourceReplyText(value: unknown, seen = new WeakSet<object>()): s

const sourceReply = asRecord(record.sourceReply) ?? asRecord(record.source_reply);
const direct = sourceReply
? firstString(sourceReply.text, sourceReply.message, sourceReply.content)
? firstString(sourceReply.text, sourceReply.message, sourceReply.content) || extractText(sourceReply)
: firstString(record.sourceReplyText, record.source_reply_text);
if (direct && !isAssistantDeliveryPlaceholder(direct)) return direct;
if (direct && !isChatCompletionPlaceholderText(direct)) return direct;

for (const key of ["content", "text", "output", "result", "data", "payload", "message"]) {
for (const key of [
"content",
"text",
"output",
"result",
"response",
"data",
"payload",
"message",
"details",
"metadata",
"__openclaw",
"toolResult",
"tool_result",
"arguments",
"args",
]) {
const text = extractSourceReplyText(record[key], seen);
if (text) return text;
}

return "";
}

function isChatCompletionPlaceholderText(text: string) {
if (isAssistantDeliveryPlaceholder(text)) return true;

const normalized = text.trim().replace(/\s+/g, " ").toLowerCase().replace(/[.!?]+$/g, "");
const ascii = normalized.replace(/[’‘]/g, "'");
const mentionsMissingResult = ascii.includes("the result didn't include") ||
ascii.includes("the result did not include") ||
ascii.includes("result didn't include") ||
ascii.includes("result did not include") ||
ascii.includes("just a completion status") ||
ascii.includes("only a completion status") ||
ascii.includes("completion status");
const asksForInputAgain = ascii.includes("paste the readme") ||
ascii.includes("share the readme text") ||
ascii.includes("run the check again") ||
ascii.includes("without the actual text") ||
ascii.includes("without the actual content") ||
ascii.includes("don't have the openclaw result") ||
ascii.includes("do not have the openclaw result");

return normalized === "done" ||
normalized === "complete" ||
normalized === "completed" ||
normalized === "ok" ||
normalized === "okay" ||
normalized === "all set" ||
mentionsMissingResult ||
asksForInputAgain;
}

function isToolOnlyMessage(value: unknown): boolean {
const message = asRecord(value);
if (!message) return false;
Expand Down Expand Up @@ -571,10 +617,13 @@ function extractHistoryMessages(result: unknown) {
.map((item) => {
const message = asRecord(item);
const role = firstString(message?.role, message?.type, message?.author);
const content = extractText(message?.content ?? message?.message ?? message);
const content = extractSourceReplyText(message) || extractText(message?.content ?? message?.message ?? message);
return { role, content };
})
.filter((message) => message.content);
.filter((message) =>
message.content &&
!(message.role === "assistant" && isChatCompletionPlaceholderText(message.content))
);
}

function gatewaySessionSortValue(session: Record<string, unknown>) {
Expand Down Expand Up @@ -1299,7 +1348,12 @@ export async function POST(request: NextRequest) {
};

const recoverMissingAssistantText = async () => {
if ((!historySnapshotStreamed && fullAssistantText) || !client) return;
if (
(!historySnapshotStreamed &&
fullAssistantText &&
!(hasToolActivity && isChatCompletionPlaceholderText(fullAssistantText))) ||
!client
) return;
const recovered = await recoverAssistantTextFromGateway({
client,
allowedSessions: allowedEventSessions,
Expand Down Expand Up @@ -1594,9 +1648,31 @@ export async function POST(request: NextRequest) {
const finalMessage = p.message || p;
const toolOnlyFinal = isToolOnlyMessage(finalMessage);
const extractedFinalText = toolOnlyFinal ? "" : extractText(finalMessage);
const finalText = extractedFinalText && isAssistantDeliveryPlaceholder(extractedFinalText)
const finalIsPlaceholder = Boolean(extractedFinalText && isChatCompletionPlaceholderText(extractedFinalText));
const shouldTreatFinalAsPlaceholder = finalIsPlaceholder && (hasToolActivity || Boolean(deliveredSourceReplyText));
const finalText = extractedFinalText && shouldTreatFinalAsPlaceholder
? deliveredSourceReplyText
: extractedFinalText;
if (!finalText && shouldTreatFinalAsPlaceholder) {
if (fullAssistantText && !isChatCompletionPlaceholderText(fullAssistantText)) {
finishStream(false);
return;
}
publishAgentModeDiagnostic({
scope: "api-chat",
event: hasToolActivity ? "chat-final.placeholder-after-tool" : "chat-final.placeholder",
sessionId: diagnosticSessionId,
detail: {
activeRunId,
elapsedMs: Date.now() - requestStartedAt,
},
});
deferCompletionUntilAssistantText(
hasToolActivity ? "placeholder-chat-final-after-tool" : "placeholder-chat-final",
hasToolActivity,
);
return;
}
if (finalText && !streamAssistantSnapshot(finalText)) {
fullAssistantText = finalText;
}
Expand Down
Loading