diff --git a/apps/agent-forge/server/__tests__/chat.test.ts b/apps/agent-forge/server/__tests__/chat.test.ts new file mode 100644 index 000000000..c931dc8b0 --- /dev/null +++ b/apps/agent-forge/server/__tests__/chat.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import request from 'supertest'; +import type { AgentSpec } from '@loushy/build-ai-agent'; +import { createApp } from '../app'; +import { RunManager, AlreadyRunningError, ApprovalPendingError } from '../runRegistry'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; + +const SPEC: AgentSpec = { + name: 'test-agent', + prompt: 'You are a helpful agent.', + provider: { type: 'mock', model: 'mock-1' }, + tools: ['current-date'], +}; + +function waitForStatus( + runManager: RunManager, + agentId: string, + predicate: (status: { status: string }) => boolean, + timeoutMs = 2000 +): Promise { + const current = runManager.status(agentId); + if (predicate(current)) return Promise.resolve(current); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + runManager.off('status', onStatus); + reject(new Error('Timed out waiting for status')); + }, timeoutMs); + function onStatus(payload: any) { + if (payload.agentId !== agentId) return; + if (predicate(payload)) { + clearTimeout(timer); + runManager.off('status', onStatus); + resolve(payload); + } + } + runManager.on('status', onStatus); + }); +} + +describe('P1/P3 chat transport (RunManager)', () => { + let baseDir: string; + let runManager: RunManager; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-p-chat-test-')); + runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + loadSpec: async () => undefined, + saveSpec: async () => {}, + }); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + it('reports an empty chat transcript for an agent that has never chatted', () => { + expect(runManager.chatState('nope')).toEqual({ agentId: 'nope', sessionId: 'nope', messages: [] }); + }); + + it('sendMessage() appends the user turn immediately, then reconciles the real ExecutionResult.messages on settle', async () => { + const chatEvents: any[] = []; + runManager.on('chat', (id: string, payload: any) => { + if (id === 'agent-chat-1') chatEvents.push(payload); + }); + + await runManager.sendMessage('agent-chat-1', 'hello there', SPEC); + + // The user message is reflected before the run settles. + expect(chatEvents[0].messages).toHaveLength(1); + expect(chatEvents[0].messages[0]).toMatchObject({ role: 'user', content: 'hello there' }); + + await waitForStatus(runManager, 'agent-chat-1', (s) => s.status === 'stopped'); + + const finalState = runManager.chatState('agent-chat-1'); + // system + user + assistant, straight from AgentExecutor's real message array. + expect(finalState.messages.some((m) => m.role === 'assistant' && m.content === 'This is a mock response.')).toBe( + true + ); + expect(finalState.messages.some((m) => m.role === 'user' && m.content === 'hello there')).toBe(true); + // The optimistic user message's id/timestamp survived reconciliation unchanged. + const optimisticUserId = chatEvents[0].messages[0].id; + const finalUserMsg = finalState.messages.find((m) => m.role === 'user'); + expect(finalUserMsg!.id).toBe(optimisticUserId); + }); + + it('continues the same conversation across two sendMessage() calls instead of starting fresh', async () => { + await runManager.sendMessage('agent-chat-2', 'please use current-date', SPEC); + await waitForStatus(runManager, 'agent-chat-2', (s) => s.status === 'stopped'); + const afterFirst = runManager.chatState('agent-chat-2').messages; + expect(afterFirst.length).toBeGreaterThanOrEqual(3); // user + tool-call assistant + tool result (+ maybe final assistant) + + await runManager.sendMessage('agent-chat-2', 'thanks', SPEC); + await waitForStatus(runManager, 'agent-chat-2', (s) => s.status === 'stopped'); + const afterSecond = runManager.chatState('agent-chat-2').messages; + + expect(afterSecond.length).toBeGreaterThan(afterFirst.length); + // Every message from the first turn is still present, in order, with the same ids. + for (let i = 0; i < afterFirst.length; i++) { + expect(afterSecond[i].id).toBe(afterFirst[i].id); + } + expect(afterSecond.some((m) => m.role === 'user' && m.content === 'thanks')).toBe(true); + }); + + it('rejects sendMessage() while a run is already in flight', async () => { + await runManager.sendMessage('agent-chat-3', 'please use current-date', SPEC); + await expect(runManager.sendMessage('agent-chat-3', 'again', SPEC)).rejects.toBeInstanceOf(AlreadyRunningError); + await waitForStatus(runManager, 'agent-chat-3', (s) => s.status === 'stopped'); + }); + + it('rejects sendMessage() while paused awaiting an approval decision', async () => { + // None of the LOU-N server's spec-resolvable tools set `needsApproval` + // (see approvalFlow.test.ts's doc comment), so a spec alone can't drive + // RunManager into a genuinely paused state here - seed the (private, + // in-memory) entry directly instead, exactly mirroring the shape + // handleRunSettled() would have produced for a real paused run. + const internals = runManager as unknown as { entries: Map }; + internals.entries.set('agent-chat-4', { + status: 'paused', + sessionId: 'agent-chat-4', + reason: 'awaiting_approval', + pendingApproval: { approvalId: 'appr-1', toolName: 'send-email', args: {}, createdAt: new Date().toISOString() }, + messages: [], + chatSessionId: 'agent-chat-4', + chatStartedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await expect(runManager.sendMessage('agent-chat-4', 'go ahead', SPEC)).rejects.toBeInstanceOf( + ApprovalPendingError + ); + }); + + it('newChat() archives the current transcript under its own session id and starts an empty one', async () => { + await runManager.sendMessage('agent-chat-5', 'first conversation', SPEC); + await waitForStatus(runManager, 'agent-chat-5', (s) => s.status === 'stopped'); + const firstSessionId = runManager.chatState('agent-chat-5').sessionId; + + const fresh = runManager.newChat('agent-chat-5'); + expect(fresh.messages).toHaveLength(0); + expect(fresh.sessionId).not.toBe(firstSessionId); + + await runManager.sendMessage('agent-chat-5', 'second conversation', SPEC); + await waitForStatus(runManager, 'agent-chat-5', (s) => s.status === 'stopped'); + + const sessions = runManager.listChats('agent-chat-5'); + expect(sessions.map((s) => s.sessionId).sort()).toEqual([firstSessionId, fresh.sessionId].sort()); + + const firstRecord = runManager.loadChatSession('agent-chat-5', firstSessionId); + expect(firstRecord!.messages.some((m) => m.content === 'first conversation')).toBe(true); + expect(firstRecord!.messages.some((m) => m.content === 'second conversation')).toBe(false); + }); + + it('persists chat history to disk and rehydrates it for a fresh RunManager instance (server-restart survival)', async () => { + await runManager.sendMessage('agent-chat-6', 'remember this', SPEC); + await waitForStatus(runManager, 'agent-chat-6', (s) => s.status === 'stopped'); + + const restarted = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + loadSpec: async () => undefined, + saveSpec: async () => {}, + }); + const rehydrated = restarted.chatState('agent-chat-6'); + expect(rehydrated.messages.some((m) => m.content === 'remember this')).toBe(true); + }); +}); + +describe('P1/P3 chat transport (HTTP routes)', () => { + let baseDir: string; + let app: ReturnType; + let runManager: RunManager; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-p-chat-app-test-')); + const agentStore = createFsAgentStore(baseDir); + runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + loadSpec: (id) => agentStore.load(id), + saveSpec: (id, spec) => agentStore.save(id, spec), + }); + app = createApp({ agentStore, runManager }); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + it('POST /agents/:id/message rejects a missing message body', async () => { + const res = await request(app).post('/agents/foo/message').send({}); + expect(res.status).toBe(400); + }); + + it('POST /agents/:id/message starts a chat turn and GET /chat reflects it once settled', async () => { + const post = await request(app).post('/agents/foo/message').send({ message: 'hi there', spec: SPEC }); + expect(post.status).toBe(202); + + await waitForStatus(runManager, 'foo', (s) => s.status === 'stopped'); + + const get = await request(app).get('/agents/foo/chat'); + expect(get.status).toBe(200); + expect(get.body.messages.some((m: any) => m.role === 'user' && m.content === 'hi there')).toBe(true); + expect(get.body.messages.some((m: any) => m.role === 'assistant')).toBe(true); + }); + + // The "already running"/"approval pending" 409 guards are covered + // deterministically at the RunManager unit level above (calling + // sendMessage() twice back-to-back with no intervening await, and by + // seeding a paused entry directly) - reproducing that race through a real + // HTTP round-trip against the near-instant mock provider is flaky for the + // same reason documented in app.test.ts's equivalent note for + // POST /agents/:id/run. + + it('POST /agents/:id/chat/new then GET /agents/:id/chats lists both sessions', async () => { + await request(app).post('/agents/foo/message').send({ message: 'first session', spec: SPEC }); + await waitForStatus(runManager, 'foo', (s) => s.status === 'stopped'); + + const newChat = await request(app).post('/agents/foo/chat/new'); + expect(newChat.status).toBe(200); + expect(newChat.body.messages).toHaveLength(0); + + const list = await request(app).get('/agents/foo/chats'); + expect(list.status).toBe(200); + expect(list.body.length).toBeGreaterThanOrEqual(1); + expect(list.body[0]).toHaveProperty('preview'); + }); + + it('GET /agents/:id/chats/:sessionId 404s for an unknown session', async () => { + const res = await request(app).get('/agents/foo/chats/does-not-exist'); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/agent-forge/server/__tests__/chatReconcile.test.ts b/apps/agent-forge/server/__tests__/chatReconcile.test.ts new file mode 100644 index 000000000..d41c9f652 --- /dev/null +++ b/apps/agent-forge/server/__tests__/chatReconcile.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import type { Message } from '@loushy/build-ai-agent'; +import { reconcileChatMessages } from '../chatReconcile'; +import type { ChatMessage } from '../types'; + +const T1 = '2026-01-01T00:00:00.000Z'; +const T2 = '2026-01-01T00:00:05.000Z'; + +function chatMsg(m: Message, id: string, timestamp = T1): ChatMessage { + return { ...m, id, timestamp }; +} + +describe('reconcileChatMessages', () => { + it('preserves the optimistic user message id/timestamp on the very first turn, despite AgentExecutor prepending a system message', () => { + const prev: ChatMessage[] = [chatMsg({ role: 'user', content: 'hi' }, 'user-1', T1)]; + const next: Message[] = [ + { role: 'system', content: 'You are a helpful agent.' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: 'Hello!' }, + ]; + + const result = reconcileChatMessages(prev, next, T2); + + expect(result).toHaveLength(3); + // The leading system message (not present in `prev` at all) is new. + expect(result[0]).toMatchObject({ role: 'system', timestamp: T2 }); + // The user message is recognized as the SAME message sendMessage() + // pushed optimistically - its id/timestamp carry over unchanged. + expect(result[1]).toMatchObject({ id: 'user-1', timestamp: T1, content: 'hi' }); + // The assistant's reply is genuinely new. + expect(result[2]).toMatchObject({ content: 'Hello!', timestamp: T2 }); + expect(result[2].id).not.toBe('user-1'); + }); + + it('preserves ids/timestamps for a matched tail and only stamps genuinely new messages', () => { + const prev: ChatMessage[] = [ + chatMsg({ role: 'system', content: 'sys' }, 'sys-1', T1), + chatMsg({ role: 'user', content: 'first' }, 'user-1', T1), + chatMsg({ role: 'assistant', content: 'reply one' }, 'asst-1', T1), + ]; + const next: Message[] = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply one' }, + { role: 'user', content: 'second' }, + { role: 'assistant', content: 'reply two' }, + ]; + + const result = reconcileChatMessages(prev, next, T2); + + expect(result).toHaveLength(5); + // The first three carry over their original ids/timestamps unchanged. + expect(result[0]).toMatchObject({ id: 'sys-1', timestamp: T1 }); + expect(result[1]).toMatchObject({ id: 'user-1', timestamp: T1 }); + expect(result[2]).toMatchObject({ id: 'asst-1', timestamp: T1 }); + // The two new messages get fresh ids and the settle timestamp. + expect(result[3]).toMatchObject({ content: 'second', timestamp: T2 }); + expect(result[4]).toMatchObject({ content: 'reply two', timestamp: T2 }); + expect(result[3].id).not.toBe('user-1'); + }); + + it('preserves a tool-call/tool-result pair by role+content+toolCallId, not just content', () => { + const prev: ChatMessage[] = [ + chatMsg( + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'current-date', arguments: '{}' } }], + }, + 'asst-1', + T1 + ), + chatMsg({ role: 'tool', content: '"2026-01-01"', toolCallId: 'call_1', toolName: 'current-date' }, 'tool-1', T1), + ]; + const next: Message[] = [ + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'current-date', arguments: '{}' } }], + }, + { role: 'tool', content: '"2026-01-01"', toolCallId: 'call_1', toolName: 'current-date' }, + { role: 'assistant', content: 'The date is 2026-01-01.' }, + ]; + + const result = reconcileChatMessages(prev, next, T2); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('asst-1'); + expect(result[1].id).toBe('tool-1'); + expect(result[2].timestamp).toBe(T2); + }); + + it('returns an empty array unchanged', () => { + expect(reconcileChatMessages([], [], T2)).toEqual([]); + }); +}); diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index f4cecd4d8..aa98c0e37 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -27,7 +27,13 @@ import express, { type Express, type Request, type Response, type NextFunction } import cors from 'cors'; import type { AgentSpec } from '@loushy/build-ai-agent'; import type { AgentStore } from '../src/persistence/AgentStore'; -import { RunManager, AgentNotFoundError, AlreadyRunningError, NoActiveRunError } from './runRegistry'; +import { + RunManager, + AgentNotFoundError, + AlreadyRunningError, + NoActiveRunError, + ApprovalPendingError, +} from './runRegistry'; import { isValidAgentId } from './types'; export interface CreateAppOptions { @@ -167,6 +173,68 @@ export function createApp({ agentStore, runManager }: CreateAppOptions): Express }) ); + // P1: chat transport. `POST /agents/:id/message` appends a user message + // and either continues the agent's existing conversation or starts a + // fresh one (see RunManager.sendMessage()'s doc comment for the exact + // continuation semantics); replies stream back over the existing + // `WS /agents/:id/stream` channel as `{type:'chat', ...}` events, not a + // separate response body here. + app.post( + '/agents/:id/message', + asyncRoute(async (req, res) => { + const { message, spec } = req.body as { message?: string; spec?: AgentSpec }; + if (typeof message !== 'string' || !message) { + res.status(400).json({ error: "Request body must include a non-empty 'message' string" }); + return; + } + try { + await runManager.sendMessage(paramId(req), message, spec); + res.status(202).json(runManager.status(paramId(req))); + } catch (error) { + if (error instanceof AlreadyRunningError || error instanceof ApprovalPendingError) { + res.status(409).json({ error: error.message }); + return; + } + if (error instanceof AgentNotFoundError) { + res.status(404).json({ error: error.message }); + return; + } + throw error; + } + }) + ); + + // P1: current live chat transcript (a REST snapshot mirroring the WS + // stream's initial `{type:'chat'}` push, for a client that wants it + // without opening a socket). + app.get('/agents/:id/chat', (req, res) => { + res.json(runManager.chatState(paramId(req))); + }); + + // P3: multi-session chat history - archives the current transcript and + // starts a new, empty one. + app.post('/agents/:id/chat/new', (req, res) => { + res.json(runManager.newChat(paramId(req))); + }); + + app.get('/agents/:id/chats', (req, res) => { + res.json(runManager.listChats(paramId(req))); + }); + + app.get('/agents/:id/chats/:sessionId', (req, res) => { + const sessionId = Array.isArray(req.params.sessionId) ? req.params.sessionId[0] : req.params.sessionId; + if (!isValidAgentId(sessionId)) { + res.status(400).json({ error: 'Invalid session id' }); + return; + } + const record = runManager.loadChatSession(paramId(req), sessionId); + if (!record) { + res.status(404).json({ error: `No chat session '${sessionId}' for agent '${paramId(req)}'` }); + return; + } + res.json(record); + }); + // O3: step-through debugger controls (Topbar's "Debug" button). See // debugController.ts for exactly what "breakpoint"/"paused" mean given // AgentExecutor's real control surface. diff --git a/apps/agent-forge/server/chatReconcile.ts b/apps/agent-forge/server/chatReconcile.ts new file mode 100644 index 000000000..4659f18c5 --- /dev/null +++ b/apps/agent-forge/server/chatReconcile.ts @@ -0,0 +1,80 @@ +/** + * P1: reconciles the chat transcript RunManager keeps for one agent with a + * fresh, authoritative `Message[]` from an `ExecutionResult` (the SDK's own + * `AgentExecutor` conversation state - see AgentExecutor.ts). + * + * Why reconcile instead of appending incrementally from ExecutionEvents: + * AgentExecutor.runAgentLoop() only ever calls `provider.generate()` (never + * `provider.stream()` - see AgentExecutor.ts), so there is no per-token or + * even per-partial-message event to append from mid-run. The + * 'text-complete'/'tool-call'/'tool-result' ExecutionEvents it does emit are + * already forwarded as `{type:'event'}` WS messages (driving the Logs tab + * and the chat's typing indicator), but building the chat transcript itself + * from that same event stream would mean reconstructing (and risking + * drifting from) AgentExecutor's own message-building logic - exactly the + * "invented parallel message store" the epic brief warns against. Instead, + * once a run settles (completes OR pauses for approval - both hand back a + * full `ExecutionResult.messages`, see runRegistry.ts's handleRunSettled), + * this reconciles that authoritative array against what the transcript + * already had, so the source of truth is always exactly what + * AgentExecutor itself saw. + * + * Reconciliation preserves `id`/`timestamp` for messages that already + * existed (matched from the tail backwards, since new messages are always + * appended - the one exception being a system prompt prepended ahead of + * everything on a conversation's very first turn, which this handles by + * treating anything before the matched suffix as "new" too) and assigns + * fresh ones for genuinely new messages. + */ +import { randomUUID } from 'node:crypto'; +import type { Message } from '@loushy/build-ai-agent'; +import type { ChatMessage } from './types'; + +function sameMessage(a: Message, b: Message): boolean { + return ( + a.role === b.role && + a.content === b.content && + a.toolCallId === b.toolCallId && + a.toolName === b.toolName && + JSON.stringify(a.toolCalls ?? null) === JSON.stringify(b.toolCalls ?? null) + ); +} + +/** Length of the common prefix between `prev` and `next`, treating `next`'s first `offset` entries as not part of `prev` at all. */ +function prefixMatchLength(prev: ChatMessage[], next: Message[], offset: number): number { + let matched = 0; + while (matched < prev.length && offset + matched < next.length && sameMessage(prev[matched], next[offset + matched])) { + matched++; + } + return matched; +} + +export function reconcileChatMessages( + prev: ChatMessage[], + next: Message[], + settledAt: string +): ChatMessage[] { + // New messages are always APPENDED to the conversation, so `prev` is + // normally an exact prefix of `next` (offset 0). The one exception is a + // conversation's very first turn: `prev` there is just the optimistic + // user message sendMessage() pushed (no system message), while `next` + // (AgentExecutor's real messages) starts with the system prompt ahead of + // it - a single extra leading entry (offset 1). Try both and keep + // whichever aligns more of `prev`, rather than hard-coding which case + // this run is in. + const offset0 = prefixMatchLength(prev, next, 0); + const offset1 = prefixMatchLength(prev, next, 1); + const offset = offset1 > offset0 ? 1 : 0; + const matched = offset === 1 ? offset1 : offset0; + + const leading: ChatMessage[] = next.slice(0, offset).map((m) => ({ ...m, id: randomUUID(), timestamp: settledAt })); + const matchedPart: ChatMessage[] = prev.slice(0, matched).map((old, idx) => ({ + ...next[offset + idx], + id: old.id, + timestamp: old.timestamp, + })); + const trailing: ChatMessage[] = next + .slice(offset + matched) + .map((m) => ({ ...m, id: randomUUID(), timestamp: settledAt })); + return [...leading, ...matchedPart, ...trailing]; +} diff --git a/apps/agent-forge/server/chatStore.ts b/apps/agent-forge/server/chatStore.ts new file mode 100644 index 000000000..e0ba6780b --- /dev/null +++ b/apps/agent-forge/server/chatStore.ts @@ -0,0 +1,71 @@ +/** + * P3: file-backed persistence for chat transcripts, one JSON file per + * (agentId, chat session) under + * `/.loushy/agents//chats/.json` - sibling to + * FileCheckpointStore.ts/FileApprovalStore.ts/fsAgentStore.ts, following the + * same `.loushy/agents//...` layout rather than a fourth, unrelated + * persistence mechanism. + * + * A "session" here is a chat conversation, not an individual run: Stop-then- + * Run resumes the same session (mirroring how `sessionId = agentId` resumes + * the same AgentExecutor conversation via checkpoints, see runRegistry.ts), + * and a session only ends when the user explicitly starts a new one + * (RunManager.newChat()) - that's what "browse past conversations" (P3) + * browses. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { ChatMessage, ChatSessionMeta, ChatSessionRecord } from './types'; + +export class FileChatStore { + constructor(private readonly baseDir: string) {} + + private dir(agentId: string): string { + return path.join(this.baseDir, '.loushy', 'agents', agentId, 'chats'); + } + + private filePath(agentId: string, sessionId: string): string { + return path.join(this.dir(agentId), `${sessionId}.json`); + } + + save(agentId: string, record: ChatSessionRecord): void { + fs.mkdirSync(this.dir(agentId), { recursive: true }); + fs.writeFileSync(this.filePath(agentId, record.sessionId), JSON.stringify(record), 'utf8'); + } + + load(agentId: string, sessionId: string): ChatSessionRecord | null { + const file = this.filePath(agentId, sessionId); + if (!fs.existsSync(file)) return null; + return JSON.parse(fs.readFileSync(file, 'utf8')) as ChatSessionRecord; + } + + /** Metadata for every persisted session for `agentId`, newest first. Malformed files are skipped rather than failing the whole listing. */ + list(agentId: string): ChatSessionMeta[] { + const dir = this.dir(agentId); + if (!fs.existsSync(dir)) return []; + const metas: ChatSessionMeta[] = []; + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.json')) continue; + try { + const record = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) as ChatSessionRecord; + metas.push({ + sessionId: record.sessionId, + startedAt: record.startedAt, + updatedAt: record.updatedAt, + messageCount: record.messages.length, + preview: record.preview, + }); + } catch { + // Skip a corrupt/partial file rather than failing the whole listing. + } + } + return metas.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); + } +} + +/** Truncated preview text of the first user message, for the session list (P3). */ +export function previewFor(messages: ChatMessage[]): string { + const firstUser = messages.find((m) => m.role === 'user'); + const text = firstUser?.content ?? ''; + return text.length > 80 ? `${text.slice(0, 80)}...` : text; +} diff --git a/apps/agent-forge/server/runRegistry.ts b/apps/agent-forge/server/runRegistry.ts index 9ba931126..97086f6eb 100644 --- a/apps/agent-forge/server/runRegistry.ts +++ b/apps/agent-forge/server/runRegistry.ts @@ -18,6 +18,7 @@ import { type Span, type TraceExporter, type ExecutionResult, + type Message, resumeAfterApproval, type AgentSpec, } from '@loushy/build-ai-agent'; @@ -25,7 +26,19 @@ import { buildAgentFromSpec } from './buildAgent'; import { withAbortSignal, RunAbortedError } from './abortableProvider'; import { FileApprovalStore } from './approvalStore'; import { DebugSession, type BreakpointKey } from './debugController'; -import type { AgentRunStatusPayload, RunStatus, LogEntry, LogLevel, LogPhase } from './types'; +import { FileChatStore, previewFor } from './chatStore'; +import { reconcileChatMessages } from './chatReconcile'; +import type { + AgentRunStatusPayload, + RunStatus, + LogEntry, + LogLevel, + LogPhase, + ChatMessage, + ChatStatePayload, + ChatSessionMeta, + ChatSessionRecord, +} from './types'; interface RunEntry { status: RunStatus; @@ -55,6 +68,18 @@ interface RunEntry { lastSpec?: AgentSpec; /** O3: live step-through debug session for this agent's in-flight run, if any. */ debugSession?: DebugSession; + /** + * P1/P3: the chat transcript for this agent's CURRENT chat session - + * reconciled from the real `ExecutionResult.messages` on every run + * settle (see chatReconcile.ts), plus the just-sent user message pushed + * optimistically by sendMessage() before the run even starts. Persisted + * to disk (chatStore) on every change so it survives a server restart. + */ + messages: ChatMessage[]; + /** P3: id of the chat session `messages` belongs to - stable across Stop/Run continuations, changes only on newChat(). */ + chatSessionId: string; + /** P3: when the current chat session started, for its ChatSessionMeta. */ + chatStartedAt: string; } export interface RunManagerOptions { @@ -70,11 +95,15 @@ export interface RunManagerOptions { loadSpec: (agentId: string) => Promise; /** Persists a run's spec to disk (fsAgentStore.save) so it survives a server restart. */ saveSpec?: (agentId: string, spec: AgentSpec) => Promise; + /** P1/P3: chat transcript persistence. Defaults to `new FileChatStore(baseDir)` when omitted. */ + chatStore?: FileChatStore; } export class AgentNotFoundError extends Error {} export class AlreadyRunningError extends Error {} export class NoActiveRunError extends Error {} +/** P1: a chat message was sent while the run was paused awaiting a tool approval decision - resolve that first via approve(). */ +export class ApprovalPendingError extends Error {} /** * O1: translates one `ExecutionEvent` (the SDK's own execution-phase @@ -143,6 +172,18 @@ function truncate(text: string | undefined, max = 400): string { return text.length > max ? `${text.slice(0, max)}...` : text; } +/** P1: strips the id/timestamp this file adds for the UI, back to the SDK's real `Message` shape for feeding into AgentExecutor.execute(). */ +function toSdkMessage(m: ChatMessage): Message { + return { + role: m.role, + content: m.content, + name: m.name, + toolCallId: m.toolCallId, + toolName: m.toolName, + toolCalls: m.toolCalls, + }; +} + export class RunManager extends EventEmitter { private readonly entries = new Map(); /** @@ -152,10 +193,13 @@ export class RunManager extends EventEmitter { * per-run() but seeded from this map. */ private readonly breakpoints = new Map>(); + /** P1/P3: chat transcript persistence (see chatStore.ts). */ + private readonly chatStore: FileChatStore; constructor(private readonly opts: RunManagerOptions) { super(); this.setMaxListeners(0); + this.chatStore = opts.chatStore ?? new FileChatStore(opts.baseDir); } status(agentId: string): AgentRunStatusPayload { @@ -177,17 +221,96 @@ export class RunManager extends EventEmitter { } private setEntry(agentId: string, patch: Partial, base?: RunEntry): RunEntry { - const prev = base ?? this.entries.get(agentId) ?? { - status: 'idle' as RunStatus, - sessionId: agentId, - updatedAt: new Date().toISOString(), - }; + const prev = + base ?? + this.entries.get(agentId) ?? + this.freshEntry(agentId); const next: RunEntry = { ...prev, ...patch, updatedAt: new Date().toISOString() }; this.entries.set(agentId, next); this.emit('status', this.status(agentId)); return next; } + private freshEntry(agentId: string): RunEntry { + const now = new Date().toISOString(); + // P3: a brand-new (never-touched-this-process) agent id picks up any + // chat transcript persisted under its default session id (`agentId` + // itself, mirroring AgentExecutor's `sessionId = agentId`) from a + // previous server run, so chat history survives a restart the same way + // saved agent specs do. + // + // Known limitation: if the LAST thing that happened before the restart + // was a newChat() (which moves the live session to a fresh randomUUID() + // id), that new session id isn't recovered here - only the default + // `agentId`-named session is. A restart in that state resumes the + // agent's FIRST session rather than whichever one was live; every + // session is still fully intact and browsable via listChats()/ + // loadChatSession(), just not auto-selected as "current". Left for + // LOU-Q: a small "current session pointer" file would close this gap. + const persisted = this.chatStore.load(agentId, agentId); + return { + status: 'idle' as RunStatus, + sessionId: agentId, + updatedAt: now, + messages: persisted?.messages ?? [], + chatSessionId: agentId, + chatStartedAt: persisted?.startedAt ?? now, + }; + } + + /** P1: current live chat transcript for `agentId` (creates no run - safe to call for an agent that has never run). */ + chatState(agentId: string): ChatStatePayload { + const entry = this.entries.get(agentId) ?? this.freshEntry(agentId); + return { agentId, sessionId: entry.chatSessionId, messages: entry.messages }; + } + + /** P3: metadata for every persisted chat session for `agentId` (including the current one, once it has been saved at least once). */ + listChats(agentId: string): ChatSessionMeta[] { + return this.chatStore.list(agentId); + } + + /** P3: a full past (or current) chat session's transcript, or undefined if unknown. */ + loadChatSession(agentId: string, sessionId: string): ChatSessionRecord | undefined { + return this.chatStore.load(agentId, sessionId) ?? undefined; + } + + private persistChat(agentId: string, entry: RunEntry): void { + const now = new Date().toISOString(); + this.chatStore.save(agentId, { + sessionId: entry.chatSessionId, + startedAt: entry.chatStartedAt, + updatedAt: now, + messageCount: entry.messages.length, + preview: previewFor(entry.messages), + messages: entry.messages, + }); + } + + private emitChat(agentId: string, entry: RunEntry): void { + this.persistChat(agentId, entry); + this.emit('chat', agentId, { agentId, sessionId: entry.chatSessionId, messages: entry.messages } satisfies ChatStatePayload); + } + + /** + * P3: archives the current chat session (already persisted incrementally + * by every prior emitChat() call, so this is really just "cut a new + * session going forward") and starts a fresh, empty one for `agentId`. + * Does not touch any in-flight run - starting a new chat while a run is + * in progress is allowed (the in-flight run keeps writing to whatever + * session it started under; sendMessage() rejects new input meanwhile + * regardless of which session id is "current"). + */ + newChat(agentId: string): ChatStatePayload { + const now = new Date().toISOString(); + const next = this.setEntry(agentId, { + messages: [], + chatSessionId: randomUUID(), + chatStartedAt: now, + }); + this.persistChat(agentId, next); + return this.chatState(agentId); + } + private emitEvent(agentId: string, event: ExecutionEvent): void { this.emit('event', agentId, { type: event.type, @@ -286,7 +409,83 @@ export class RunManager extends EventEmitter { if (existing && existing.status === 'running') { throw new AlreadyRunningError(`Agent '${agentId}' is already running`); } + await this.launch(agentId, input, spec); + } + + /** + * P1: `POST /agents/:id/message` - appends a chat message to the agent's + * conversation and either continues it (if this agent has any prior chat + * history) or starts a brand-new one, replying over the existing + * `WS /agents/:id/stream` channel as `{type:'chat', ...}` events rather + * than a new one. + * + * Continuation semantics (the "your call which" from the epic brief): + * - A run already in flight for this agent -> rejected (AlreadyRunningError, + * surfaced as 409). A chat message while the agent is mid-turn has + * nowhere sensible to go until that turn finishes; the input box stays + * disabled client-side while `status === 'running'` for the same reason. + * - Paused awaiting a tool approval -> rejected (ApprovalPendingError, + * 409). The approval card rendered inline in the thread is the only + * valid next step; free-text chat can't resolve a pending tool call. + * - Otherwise (idle/stopped/error, i.e. no run currently owns this + * agent's conversation): if this agent has ANY prior chat messages, + * the new message CONTINUES that exact conversation - the real + * `Message[]` AgentExecutor last produced (stripped of the id/timestamp + * fields this file adds for the UI) plus the new user turn, passed as + * `input` with `skipSystemPromptInjection: true` so AgentExecutor + * doesn't inject a second system message ahead of the one already in + * that history. A genuinely new agent (no prior chat) passes the plain + * string instead, so AgentExecutor's normal buildMessages() path builds + * the system+user turn from scratch exactly like a Topbar "Run" click. + * + * Known limitation (left for LOU-Q): if the agent was Stopped mid-flight + * (a checkpoint exists from an in-progress run, not a completed one), + * AgentExecutor.execute() ignores `input` entirely and resumes straight + * from that checkpoint (see run()'s original doc comment above) - so a + * chat message sent in that state is shown optimistically in the thread + * but is NOT actually incorporated into the resumed run's conversation. + * This mirrors existing Stop→Run checkpoint-resume behavior rather than + * inventing new semantics for it; a future epic may want a dedicated + * "discard checkpoint and continue with this new message instead" action. + */ + async sendMessage(agentId: string, text: string, spec?: AgentSpec): Promise { + const existing = this.entries.get(agentId) ?? this.freshEntry(agentId); + if (existing.status === 'running') { + throw new AlreadyRunningError(`Agent '${agentId}' is already running`); + } + if (existing.status === 'paused' && existing.reason === 'awaiting_approval') { + throw new ApprovalPendingError( + `Agent '${agentId}' is paused awaiting an approval decision - resolve it via approve() before sending another message` + ); + } + + const now = new Date().toISOString(); + const userMessage: ChatMessage = { role: 'user', content: text, id: randomUUID(), timestamp: now }; + const priorMessages = existing.messages; + const withUser = this.setEntry(agentId, { messages: [...priorMessages, userMessage] }, existing); + this.emitChat(agentId, withUser); + + const input: string | Message[] = + priorMessages.length > 0 ? [...priorMessages.map(toSdkMessage), toSdkMessage(userMessage)] : text; + + await this.launch(agentId, input, spec, { skipSystemPromptInjection: priorMessages.length > 0 }); + } + /** + * Shared run-kickoff for both run() and sendMessage() - builds the + * agent/provider/toolRegistry, flips status to 'running', and fires off + * the AgentExecutor.execute() call, wiring its settle/failure back into + * this registry's status/chat broadcasts exactly like the original run() + * did (see that method's doc comment above, still accurate for the + * sessionId=agentId / checkpoint-resume semantics this shares). + */ + private async launch( + agentId: string, + input: string | Message[], + spec: AgentSpec | undefined, + options: { skipSystemPromptInjection?: boolean } = {} + ): Promise { + const existing = this.entries.get(agentId); const resolvedSpec = spec ?? existing?.lastSpec ?? (await this.opts.loadSpec(agentId)); if (!resolvedSpec) { throw new AgentNotFoundError(`No agent spec for id '${agentId}' (none supplied and none saved)`); @@ -336,6 +535,7 @@ export class RunManager extends EventEmitter { approvalStore: this.opts.approvalStore, onEvent: (event) => this.emitEvent(agentId, event), exporter: this.makeTraceExporter(agentId), + skipSystemPromptInjection: options.skipSystemPromptInjection, ...debugSession.hooks(), }) .then((result) => this.handleRunSettled(agentId, result)) @@ -346,6 +546,36 @@ export class RunManager extends EventEmitter { /** Shared success-path handling for both run() and approve()'s follow-up execute() call. */ private async handleRunSettled(agentId: string, result: ExecutionResult): Promise { + // P1: reconcile the chat transcript against the real, authoritative + // ExecutionResult.messages - see chatReconcile.ts's doc comment for why + // this (rather than building the transcript incrementally from + // ExecutionEvents) is the single source of truth. Done for BOTH the + // completed and paused-for-approval branches below, since both hand + // back a full `messages` array (the pause happens exactly at the point + // the assistant's tool-call message was added). + const settledAt = new Date().toISOString(); + const entryBefore = this.entries.get(agentId); + // SDK bug found while wiring this up and FIXED AT THE SOURCE as part of + // this epic (src/execution/AgentExecutor.ts's no-more-tool-calls exit + // path now pushes the assistant's final reply onto `currentMessages` + // before returning, so `result.messages` already includes it). The + // guard below is kept as a now-provably-redundant safety net rather + // than removed outright - it only appends when the last message is NOT + // already that exact assistant/text pair, which is always true against + // a fixed SDK, so this is a harmless no-op today. Left in case a caller + // ever runs this app against an older/vendored SDK build that predates + // the fix. + const authoritative: Message[] = + result.text && + !( + result.messages.length > 0 && + result.messages[result.messages.length - 1].role === 'assistant' && + result.messages[result.messages.length - 1].content === result.text + ) + ? [...result.messages, { role: 'assistant', content: result.text }] + : result.messages; + const reconciledMessages = reconcileChatMessages(entryBefore?.messages ?? [], authoritative, settledAt); + // O4: the full ExecutionResult (messages/toolCalls/usage/steps, not // just the final text) is kept on the entry either way, so the // client's Output tab can render it as a JSON tree whether the run @@ -353,7 +583,7 @@ export class RunManager extends EventEmitter { // ExecutionResult" per the epic brief. if (result.finishReason === 'awaiting-approval' && result.approvalId) { const record = await this.opts.approvalStore.peek(agentId, result.approvalId); - this.setEntry(agentId, { + const next = this.setEntry(agentId, { status: 'paused', reason: 'awaiting_approval', pendingApproval: { @@ -363,10 +593,19 @@ export class RunManager extends EventEmitter { createdAt: record?.pending.createdAt ?? new Date().toISOString(), }, result, + messages: reconciledMessages, }); + this.emitChat(agentId, next); return; } - this.setEntry(agentId, { status: 'stopped', resultText: result.text, result, controller: undefined }); + const next = this.setEntry(agentId, { + status: 'stopped', + resultText: result.text, + result, + controller: undefined, + messages: reconciledMessages, + }); + this.emitChat(agentId, next); } /** Shared failure-path handling for both run() and approve()'s follow-up execute() call. */ diff --git a/apps/agent-forge/server/types.ts b/apps/agent-forge/server/types.ts index 7ce5d594e..eda30fd6c 100644 --- a/apps/agent-forge/server/types.ts +++ b/apps/agent-forge/server/types.ts @@ -109,6 +109,49 @@ export interface DebugStatePayload { breakpoints: string[]; } +/** + * P1/P2: one message in the chat thread, mapped 1:1 from the SDK's real + * `Message` (src/providers/llm.ts) rather than a separate invented shape - + * only `id`/`timestamp` are added (the SDK's `Message` carries neither), + * for React keys and the mockup's per-bubble timestamp. `role: 'system'` + * messages are included for completeness (they're part of the real + * conversation AgentExecutor sees) but the client filters them out of the + * rendered bubble list, matching the mockup (which only ever shows + * user/agent bubbles). + */ +export interface ChatMessage { + id: string; + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + name?: string; + toolCallId?: string; + toolName?: string; + toolCalls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]; + timestamp: string; +} + +/** P1: the live chat transcript for one agent's current session, pushed over WS and returned by `GET /agents/:id/chat`. */ +export interface ChatStatePayload { + agentId: string; + sessionId: string; + messages: ChatMessage[]; +} + +/** P3: metadata for one past (or current) chat session, as listed by `GET /agents/:id/chats`. */ +export interface ChatSessionMeta { + sessionId: string; + startedAt: string; + updatedAt: string; + messageCount: number; + /** Truncated text of the first user message, for a browsable session list. */ + preview: string; +} + +/** P3: a full past chat session, as returned by `GET /agents/:id/chats/:sessionId`. */ +export interface ChatSessionRecord extends ChatSessionMeta { + messages: ChatMessage[]; +} + /** * A single WS message pushed to `WS /agents/:id/stream` subscribers. * `type: 'status'` carries the full AgentRunStatusPayload (sent on every @@ -117,14 +160,17 @@ export interface DebugStatePayload { * tool-result/finish/error) for lightweight visibility into an in-progress * run. `type: 'log'`/`'span'`/`'debug'` are LOU-O's structured log stream * (O1), span waterfall (O2) and step-debugger state (O3), all derived from - * the same run rather than a second parallel event system. + * the same run rather than a second parallel event system. `type: 'chat'` + * (P1) is the live chat transcript, sent on every reconciled update and + * once immediately on connect (mirroring 'status'). */ export type StreamMessage = | { type: 'status'; payload: AgentRunStatusPayload } | { type: 'event'; payload: Record } | { type: 'log'; payload: LogEntry } | { type: 'span'; payload: SpanEvent } - | { type: 'debug'; payload: DebugStatePayload }; + | { type: 'debug'; payload: DebugStatePayload } + | { type: 'chat'; payload: ChatStatePayload }; /** * Every `:id`/agentId this server touches ends up interpolated into a diff --git a/apps/agent-forge/server/wsServer.ts b/apps/agent-forge/server/wsServer.ts index 7a535732d..5995ccaee 100644 --- a/apps/agent-forge/server/wsServer.ts +++ b/apps/agent-forge/server/wsServer.ts @@ -14,7 +14,14 @@ import type { Server as HttpServer, IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import { WebSocketServer, WebSocket } from 'ws'; import type { RunManager } from './runRegistry'; -import { isValidAgentId, type StreamMessage, type LogEntry, type SpanEvent, type DebugStatePayload } from './types'; +import { + isValidAgentId, + type StreamMessage, + type LogEntry, + type SpanEvent, + type DebugStatePayload, + type ChatStatePayload, +} from './types'; const STREAM_PATH_RE = /^\/agents\/([^/]+)\/stream$/; @@ -62,6 +69,15 @@ export function attachWebSocketServer(server: HttpServer, runManager: RunManager for (const ws of sockets) send(ws, { type: 'debug', payload }); }); + // P1: same fan-out pattern - the chat transcript, reconciled from the + // real ExecutionResult.messages (see chatReconcile.ts), over this same + // channel rather than a second WS connection. + runManager.on('chat', (agentId: string, payload: ChatStatePayload) => { + const sockets = subscribers.get(agentId); + if (!sockets) return; + for (const ws of sockets) send(ws, { type: 'chat', payload }); + }); + server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => { const url = new URL(req.url ?? '', 'http://localhost'); const match = STREAM_PATH_RE.exec(url.pathname); @@ -89,6 +105,9 @@ export function attachWebSocketServer(server: HttpServer, runManager: RunManager // Send current status immediately so a client connecting mid-run (or // after a pause) doesn't have to wait for the next transition. send(ws, { type: 'status', payload: runManager.status(agentId) }); + // P1: same for the chat transcript, so opening the Chat tab shows + // history immediately rather than waiting for the next message. + send(ws, { type: 'chat', payload: runManager.chatState(agentId) }); ws.on('close', () => { sockets!.delete(ws); diff --git a/apps/agent-forge/src/components/ApprovalCard.tsx b/apps/agent-forge/src/components/ApprovalCard.tsx new file mode 100644 index 000000000..e657cb9d4 --- /dev/null +++ b/apps/agent-forge/src/components/ApprovalCard.tsx @@ -0,0 +1,52 @@ +/** + * P2: shared approve/reject UI for a pending tool-call approval (LOU-N's + * human-in-the-loop gate, `POST /agents/:id/approve`). Factored out of + * Topbar.tsx's original inline `.approval-card` markup so the Chat tab's + * inline-in-thread approval card (the epic brief's requirement) and the + * Topbar's compact one render from the exact same component/logic instead + * of two copies that could drift - only the wrapping/placement differs + * (Topbar renders it inline in its button row; ChatPanel renders it as a + * message-thread bubble), which is left to the caller via `className`. + */ +import { useState } from 'react'; +import { useAppState } from '../state/AppState'; +import { RuntimeApiError } from '../runtime/runtimeClient'; + +export interface ApprovalCardProps { + toolName: string; + args: Record; + className?: string; +} + +export function ApprovalCard({ toolName, args, className }: ApprovalCardProps) { + const { approveAgent } = useAppState(); + const [error, setError] = useState(undefined); + const [pending, setPending] = useState<'approve' | 'reject' | undefined>(undefined); + + async function handleDecision(approved: boolean) { + setError(undefined); + setPending(approved ? 'approve' : 'reject'); + try { + await approveAgent(approved); + } catch (err) { + setError(err instanceof RuntimeApiError ? err.message : (err as Error).message); + } finally { + setPending(undefined); + } + } + + return ( +
+ + Approve {toolName}? + + + + {error && {error}} +
+ ); +} diff --git a/apps/agent-forge/src/components/BottomDrawer.tsx b/apps/agent-forge/src/components/BottomDrawer.tsx index bf3706b60..d22ed4517 100644 --- a/apps/agent-forge/src/components/BottomDrawer.tsx +++ b/apps/agent-forge/src/components/BottomDrawer.tsx @@ -4,6 +4,7 @@ import { LogsPanel } from './debug/LogsPanel'; import { TracePanel } from './debug/TracePanel'; import { OutputPanel } from './debug/OutputPanel'; import { DebugBar } from './debug/DebugBar'; +import { ChatPanel } from './ChatPanel'; const TABS: { id: DrawerTab; label: string }[] = [ { id: 'chat', label: 'Chat' }, @@ -42,9 +43,9 @@ export function BottomDrawer() { {drawerTab === 'trace' && }
- {drawerTab === 'chat' &&
Chat with a running agent is wired up in LOU-P.
} + {drawerTab === 'chat' && } {drawerTab === 'logs' && } {drawerTab === 'trace' && } {drawerTab === 'output' && } diff --git a/apps/agent-forge/src/components/ChatPanel.tsx b/apps/agent-forge/src/components/ChatPanel.tsx new file mode 100644 index 000000000..818bfba87 --- /dev/null +++ b/apps/agent-forge/src/components/ChatPanel.tsx @@ -0,0 +1,212 @@ +/** + * P1/P2: chat with a running agent - the Chat tab's real content, replacing + * BottomDrawer's LOU-P placeholder. Renders `AppState.chat` (the server's + * real `Message[]` history, reconciled and streamed over the existing + * `WS /agents/:id/stream` channel - see runRegistry.ts/chatReconcile.ts), + * ported from `.design-ref/agent-forge-mockup.html`'s bubble/avatar/ + * timestamp layout. + * + * Streaming granularity: MESSAGE-level, not token-level. AgentExecutor's + * execution loop (src/execution/AgentExecutor.ts) only ever calls + * `provider.generate()` - never `provider.stream()` - so there is no + * per-token event to render incrementally even for a provider (like + * MockLLMProvider) that DOES implement `.stream()`. A message appears in + * the thread once its run turn settles (completes, or pauses for + * approval); the "typing" indicator below fills the gap, driven by the + * real `status: 'running'` from LOU-N rather than a fake timeout. + */ +import { useEffect, useRef, useState } from 'react'; +import { useAppState } from '../state/AppState'; +import type { ChatMessage } from '../runtime/runtimeClient'; +import { ApprovalCard } from './ApprovalCard'; + +function formatTime(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '' : d.toTimeString().slice(0, 8); +} + +function initialsFor(role: ChatMessage['role']): string { + return role === 'user' ? 'You' : 'AI'; +} + +/** Tool-result messages, indexed by the toolCallId they answer, for pairing under an assistant bubble's tool-call cards. */ +function toolResultsByCallId(messages: ChatMessage[]): Map { + const map = new Map(); + for (const m of messages) { + if (m.role === 'tool' && m.toolCallId) map.set(m.toolCallId, m); + } + return map; +} + +function ToolCallCard({ toolCall, result }: { toolCall: NonNullable[number]; result?: ChatMessage }) { + let args: unknown = toolCall.function.arguments; + try { + args = JSON.parse(toolCall.function.arguments); + } catch { + // Leave as the raw string if it isn't valid JSON. + } + return ( +
+ → {toolCall.function.name}({JSON.stringify(args)}) + {result &&
= {result.content}
} +
+ ); +} + +function Bubble({ message, toolResults }: { message: ChatMessage; toolResults: Map }) { + const role = message.role === 'user' ? 'user' : 'agent'; + return ( +
+
{initialsFor(message.role)}
+
+ {message.content &&
{message.content}
} + {message.toolCalls?.map((tc) => ( +
+ +
+ ))} +
{formatTime(message.timestamp)}
+
+
+ ); +} + +export function ChatPanel() { + const { + chat, + sendChatMessage, + chatActionError, + chatSessions, + startNewChat, + viewedChatSession, + viewChatSession, + returnToLiveChat, + runStatus, + } = useAppState(); + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + + const viewing = viewedChatSession !== undefined; + const displayedMessages = viewing ? viewedChatSession!.messages : chat.messages; + const renderable = displayedMessages.filter((m) => m.role !== 'system'); + const toolResults = toolResultsByCallId(displayedMessages); + const isRunning = !viewing && runStatus?.status === 'running'; + const isPausedForApproval = !viewing && runStatus?.status === 'paused' && !!runStatus.pendingApproval; + const inputDisabled = viewing || isRunning || isPausedForApproval; + + useEffect(() => { + listRef.current?.scrollTo({ top: listRef.current.scrollHeight }); + }, [renderable.length, isRunning]); + + async function handleSend() { + const text = input.trim(); + if (!text || inputDisabled) return; + setSending(true); + try { + await sendChatMessage(text); + setInput(''); + } catch { + // chatActionError already surfaces this - keep the draft so the user can retry. + } finally { + setSending(false); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + void handleSend(); + } + } + + return ( +
+
+ {viewing ? ( + + Viewing past conversation from {new Date(viewedChatSession!.startedAt).toLocaleString()} + + + ) : ( + <> + + + + )} +
+ +
+ {renderable.length === 0 && !isRunning &&
Send a message to start chatting with this agent.
} + {renderable.map((m) => ( + + ))} + {isRunning && ( +
+
AI
+
+
+ + + +
+
+
+ )} + {isPausedForApproval && runStatus?.pendingApproval && ( +
+
AI
+
+ +
+
+ )} +
+ + {chatActionError &&
{chatActionError}
} + +
+