diff --git a/apps/web/app/api/web-sessions/[id]/route.ts b/apps/web/app/api/web-sessions/[id]/route.ts index a2a900c56bd..b9a622e3178 100644 --- a/apps/web/app/api/web-sessions/[id]/route.ts +++ b/apps/web/app/api/web-sessions/[id]/route.ts @@ -1,6 +1,6 @@ -import { readFileSync, existsSync, unlinkSync } from "node:fs"; +import { readFileSync, existsSync, readdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { resolveWebChatDir } from "@/lib/workspace"; +import { resolveWebChatDir, resolveOpenClawStateDir } from "@/lib/workspace"; import { enrichSubagentSessionFromTranscript } from "@/lib/active-runs"; import { readIndex, writeIndex } from "../shared"; @@ -18,12 +18,194 @@ export type ChatLine = { timestamp: string; }; -/** - * For subagent sessions whose persisted parts lack tool-invocation entries, - * backfill from the gateway's on-disk session transcript (which always - * stores the full conversation including tool calls). - */ -/** GET /api/web-sessions/[id] — read all messages for a web chat session */ +/* ─── Agent session fallback helpers ─── */ + +function findAgentSessionFile(sessionId: string): string | null { + const agentsDir = join(resolveOpenClawStateDir(), "agents"); + if (!existsSync(agentsDir)) return null; + try { + for (const d of readdirSync(agentsDir, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + const p = join(agentsDir, d.name, "sessions", `${sessionId}.jsonl`); + if (existsSync(p)) return p; + } + } catch { /* ignore */ } + return null; +} + +function parseAgentTranscriptToChatLines(content: string): ChatLine[] { + const lines = content.trim().split("\n").filter((l) => l.trim()); + const messages: ChatLine[] = []; + const pendingToolCalls = new Map(); + let currentAssistant: ChatLine | null = null; + + const flushAssistant = () => { + if (!currentAssistant) {return;} + const textSummary = (currentAssistant.parts ?? []) + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text as string) + .join("\n") + .slice(0, 200); + currentAssistant.content = textSummary; + messages.push(currentAssistant); + currentAssistant = null; + }; + + for (const line of lines) { + let entry: Record; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.type !== "message" || !entry.message) continue; + + const msg = entry.message as Record; + const role = msg.role as string; + + if (role === "toolResult") { + const toolCallId = msg.toolCallId as string ?? ""; + const rawContent = msg.content; + const outputText = typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? (rawContent as Array<{ type: string; text?: string }>) + .filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") + : JSON.stringify(rawContent ?? ""); + let result: unknown; + try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; } + + const assistantParts = currentAssistant?.parts; + if (assistantParts) { + const tc = assistantParts.find( + (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, + ); + if (tc) { + delete tc.state; + tc.result = result; + continue; + } + } + + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role !== "assistant") continue; + const tc = messages[i].parts?.find( + (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, + ); + if (tc) { + delete tc.state; + tc.result = result; + } + break; + } + continue; + } + + if (role === "user") { + flushAssistant(); + } + + if (role !== "user" && role !== "assistant") continue; + + const parts: Array> = []; + + if (Array.isArray(msg.content)) { + for (const part of msg.content as Array>) { + if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { + parts.push({ type: "text", text: part.text }); + } else if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) { + parts.push({ type: "reasoning", text: part.thinking }); + } else if (part.type === "toolCall") { + const toolName = (part.name ?? part.toolName ?? "unknown") as string; + const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string; + const args = part.arguments ?? part.input ?? part.args ?? {}; + pendingToolCalls.set(toolCallId, { toolName, args }); + parts.push({ + type: "tool-invocation", + toolCallId, + toolName, + args, + }); + } else if (part.type === "tool_use" || part.type === "tool-call") { + const toolName = (part.name ?? part.toolName ?? "unknown") as string; + const toolCallId = (part.id ?? part.toolCallId ?? `tool-${Date.now()}-${Math.random().toString(36).slice(2)}`) as string; + const args = part.input ?? part.args ?? {}; + pendingToolCalls.set(toolCallId, { toolName, args }); + parts.push({ + type: "tool-invocation", + toolCallId, + toolName, + args, + }); + // Legacy inline tool results + } else if (part.type === "tool_result" || part.type === "tool-result") { + const toolCallId = (part.tool_use_id ?? part.toolCallId ?? "") as string; + const pending = pendingToolCalls.get(toolCallId); + const raw = part.content ?? part.output; + const outputText = typeof raw === "string" + ? raw + : Array.isArray(raw) + ? (raw as Array<{ type: string; text?: string }>).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n") + : JSON.stringify(raw ?? ""); + let result: unknown; + try { result = JSON.parse(outputText); } catch { result = { output: outputText.slice(0, 5000) }; } + + const existingMsg = messages[messages.length - 1]; + if (existingMsg) { + const tc = existingMsg.parts?.find( + (p) => p.type === "tool-invocation" && p.toolCallId === toolCallId, + ); + if (tc) { + delete tc.state; + tc.result = result; + continue; + } + } + parts.push({ + type: "tool-invocation", + toolCallId, + toolName: pending?.toolName ?? "tool", + args: pending?.args ?? {}, + result, + }); + } + } + } else if (typeof msg.content === "string" && msg.content.trim()) { + parts.push({ type: "text", text: msg.content }); + } + + if (parts.length > 0) { + const timestamp = (entry.timestamp as string) ?? new Date((entry.ts as number) ?? Date.now()).toISOString(); + if (role === "assistant") { + if (!currentAssistant) { + currentAssistant = { + id: (entry.id as string) ?? `msg-${messages.length}`, + role: "assistant", + content: "", + parts: [], + timestamp, + }; + } + currentAssistant.parts = [...(currentAssistant.parts ?? []), ...parts]; + currentAssistant.timestamp = timestamp; + } else { + messages.push({ + id: (entry.id as string) ?? `msg-${messages.length}`, + role: "user", + content: parts + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text as string) + .join("\n") + .slice(0, 200), + parts, + timestamp, + }); + } + } + } + flushAssistant(); + return messages; +} + +/** GET /api/web-sessions/[id] — read all messages for a web chat session. + * Falls back to agent session directories when no web session is found, + * enabling ChatPanel to load cron run transcripts transparently. */ export async function GET( _request: Request, { params }: { params: Promise<{ id: string }> }, @@ -34,25 +216,31 @@ export async function GET( } const filePath = join(resolveWebChatDir(), `${id}.jsonl`); - if (!existsSync(filePath)) { - return Response.json({ error: "Session not found" }, { status: 404 }); + if (existsSync(filePath)) { + const content = readFileSync(filePath, "utf-8"); + const messages: ChatLine[] = content + .trim() + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + try { return JSON.parse(line) as ChatLine; } catch { return null; } + }) + .filter((m): m is ChatLine => m !== null); + return Response.json({ id, messages }); } - const content = readFileSync(filePath, "utf-8"); - const messages: ChatLine[] = content - .trim() - .split("\n") - .filter((line) => line.trim()) - .map((line) => { - try { - return JSON.parse(line) as ChatLine; - } catch { - return null; - } - }) - .filter((m): m is ChatLine => m !== null); + // Fallback: search agent session directories (cron runs, CLI sessions) + const agentFile = findAgentSessionFile(id); + if (agentFile) { + const content = readFileSync(agentFile, "utf-8"); + const messages = parseAgentTranscriptToChatLines(content); + return Response.json( + { id, messages }, + { headers: { "X-Session-Source": "agent" } }, + ); + } - return Response.json({ id, messages }); + return Response.json({ error: "Session not found" }, { status: 404 }); } /** DELETE /api/web-sessions/[id] — delete a web chat session */