diff --git a/src/app/api/chat/route.test.ts b/src/app/api/chat/route.test.ts index e5319fd..5947a4c 100644 --- a/src/app/api/chat/route.test.ts +++ b/src/app/api/chat/route.test.ts @@ -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({ diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index fc28dac..5b09daf 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -308,11 +308,27 @@ function extractSourceReplyText(value: unknown, seen = new WeakSet()): 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; } @@ -320,6 +336,36 @@ function extractSourceReplyText(value: unknown, seen = new WeakSet()): s 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; @@ -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) { @@ -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, @@ -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; }