From 333c7465db2adbbf1420fa26185a0b3a4aeb05ab Mon Sep 17 00:00:00 2001 From: DaniAkash Date: Thu, 23 Apr 2026 16:46:11 +0530 Subject: [PATCH] feat: add openclaw chat history APIs --- .../apps/server/src/api/routes/openclaw.ts | 55 ++++ .../services/openclaw/openclaw-cli-client.ts | 174 +++++++++++ .../api/services/openclaw/openclaw-service.ts | 288 ++++++++++++++++++ .../server/tests/api/routes/openclaw.test.ts | 174 +++++++++++ .../openclaw/openclaw-cli-client.test.ts | 103 +++++++ .../openclaw/openclaw-service.test.ts | 114 +++++++ 6 files changed, 908 insertions(+) diff --git a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts index 3c2f7b70f..bcfaa7c95 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts @@ -51,6 +51,16 @@ function getPodmanOverrideValidationError(body: { return null } +function parsePositiveIntQuery( + value: string | undefined, + fallback: number, +): number { + if (value === undefined) return fallback + const parsed = Number(value) + if (!Number.isFinite(parsed)) return fallback + return Math.max(1, Math.trunc(parsed)) +} + export function createOpenClawRoutes() { return new Hono() .get('/status', async (c) => { @@ -224,6 +234,51 @@ export function createOpenClawRoutes() { } }) + .get('/agents/:id/sessions', async (c) => { + const { id } = c.req.param() + const limit = parsePositiveIntQuery(c.req.query('limit'), 20) + + try { + const sessions = await getOpenClawService().listSessions(id) + return c.json({ + agentId: id, + sessions: sessions.slice(0, Math.min(limit, 100)), + }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return c.json({ error: message }, 500) + } + }) + + .get('/agents/:id/session', async (c) => { + const { id } = c.req.param() + + try { + const session = await getOpenClawService().resolveAgentSession(id) + return c.json(session) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return c.json({ error: message }, 500) + } + }) + + .get('/agents/:id/history', async (c) => { + const { id } = c.req.param() + const limit = parsePositiveIntQuery(c.req.query('limit'), 50) + + try { + const page = await getOpenClawService().getAgentHistoryPage(id, { + sessionKey: c.req.query('sessionKey'), + cursor: c.req.query('cursor'), + limit, + }) + return c.json(page) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return c.json({ error: message }, 500) + } + }) + .post('/agents/:id/chat', async (c) => { const { id } = c.req.param() const body = await c.req.json<{ diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts index 93216791b..b1aa188f2 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts @@ -31,6 +31,37 @@ export interface OpenClawAgentRecord { model?: string } +export interface OpenClawSessionEntry { + key: string + updatedAt: number + sessionId: string + agentId: string + kind: string + status?: string + totalTokens?: number + model?: string + modelProvider?: string +} + +export interface OpenClawChatBlock { + type: 'text' | 'toolCall' | 'thinking' + text?: string + name?: string + arguments?: unknown + thinking?: string +} + +export interface OpenClawChatMessage { + role: 'user' | 'assistant' | 'toolResult' + content: OpenClawChatBlock[] + timestamp?: number + usage?: { input: number; output: number } + stopReason?: string + toolName?: string + toolCallId?: string + isError?: boolean +} + export class OpenClawCliClient { constructor(private readonly executor: ContainerExecutor) {} @@ -191,6 +222,53 @@ export class OpenClawCliClient { await this.listAgents() } + async listSessions(agentId?: string): Promise { + const args = ['sessions', '--json'] + if (agentId) { + args.push('--agent', agentId) + } else { + args.push('--all-agents') + } + + const output = await this.runCommand(args) + const parsed = parseFirstMatchingJson< + { sessions?: unknown[]; count?: number } | unknown[] + >(output, isSessionListPayload) + + if (parsed === null) { + throw new Error( + `Failed to parse OpenClaw sessions output: ${output.slice(0, 200)}`, + ) + } + + const entries = Array.isArray(parsed) ? parsed : (parsed.sessions ?? []) + return entries.map(toSessionEntry) + } + + async getChatHistory(sessionKey: string): Promise { + const output = await this.runCommand([ + 'gateway', + 'call', + 'chat.history', + '--params', + JSON.stringify({ sessionKey }), + '--json', + ]) + + const parsed = parseFirstMatchingJson<{ messages?: unknown[] }>( + output, + (value) => isPlainObject(value) && 'messages' in value, + ) + + if (parsed === null) { + throw new Error( + `Failed to parse OpenClaw chat history output: ${output.slice(0, 200)}`, + ) + } + + return (parsed.messages ?? []).map(toChatMessage) + } + private agentWorkspace(name: string): string { return name === 'main' ? `${OPENCLAW_CONTAINER_HOME}/workspace` @@ -405,3 +483,99 @@ function isStructuredLogPayload(value: unknown): boolean { (typeof value.message === 'string' || typeof value.msg === 'string') ) } + +function isSessionListPayload(value: unknown): boolean { + if (Array.isArray(value)) return true + if (!isPlainObject(value)) return false + return 'sessions' in value || 'count' in value +} + +function toSessionEntry(raw: unknown): OpenClawSessionEntry { + const record = isPlainObject(raw) ? raw : {} + return { + key: String(record.key ?? ''), + updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0, + sessionId: String(record.sessionId ?? ''), + agentId: String(record.agentId ?? ''), + kind: String(record.kind ?? ''), + status: typeof record.status === 'string' ? record.status : undefined, + totalTokens: + typeof record.totalTokens === 'number' ? record.totalTokens : undefined, + model: typeof record.model === 'string' ? record.model : undefined, + modelProvider: + typeof record.modelProvider === 'string' + ? record.modelProvider + : undefined, + } +} + +function toChatMessage(raw: unknown): OpenClawChatMessage { + const record = isPlainObject(raw) ? raw : {} + const role = isOpenClawMessageRole(record.role) ? record.role : 'assistant' + const message: OpenClawChatMessage = { + role, + content: toChatBlocks(record.content), + } + + if (typeof record.timestamp === 'number') message.timestamp = record.timestamp + if (isPlainObject(record.usage)) { + const { input, output } = record.usage + if (typeof input === 'number' && typeof output === 'number') { + message.usage = { input, output } + } + } + if (typeof record.stopReason === 'string') { + message.stopReason = record.stopReason + } + if (typeof record.toolName === 'string') message.toolName = record.toolName + if (typeof record.toolCallId === 'string') { + message.toolCallId = record.toolCallId + } + if (typeof record.isError === 'boolean') message.isError = record.isError + + return message +} + +function toChatBlocks(content: unknown): OpenClawChatBlock[] { + if (typeof content === 'string') { + return [{ type: 'text', text: content }] + } + + if (!Array.isArray(content)) return [] + + const blocks: OpenClawChatBlock[] = [] + for (const rawBlock of content) { + if (!isPlainObject(rawBlock)) continue + + if (rawBlock.type === 'toolCall') { + const block: OpenClawChatBlock = { type: 'toolCall' } + if (typeof rawBlock.name === 'string') block.name = rawBlock.name + if (rawBlock.arguments !== undefined) { + block.arguments = rawBlock.arguments + } + blocks.push(block) + continue + } + + if (rawBlock.type === 'thinking') { + const block: OpenClawChatBlock = { type: 'thinking' } + if (typeof rawBlock.thinking === 'string') { + block.thinking = rawBlock.thinking + } + blocks.push(block) + continue + } + + const block: OpenClawChatBlock = { type: 'text' } + if (typeof rawBlock.text === 'string') block.text = rawBlock.text + blocks.push(block) + } + + return blocks +} + +function isOpenClawMessageRole( + value: unknown, +): value is OpenClawChatMessage['role'] { + return value === 'user' || value === 'assistant' || value === 'toolResult' +} diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index d23362655..ecc3e9eda 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -30,8 +30,10 @@ import { } from './errors' import { type OpenClawAgentRecord, + type OpenClawChatMessage, OpenClawCliClient, type OpenClawConfigBatchEntry, + type OpenClawSessionEntry, } from './openclaw-cli-client' import { getHostWorkspaceDir, @@ -115,6 +117,188 @@ export interface OpenClawPodmanOverridesResponse { effectivePodmanPath: string } +export type OpenClawSessionSource = + | 'user-chat' + | 'cron' + | 'hook' + | 'channel' + | 'other' + +export interface BrowserOSOpenClawSession { + key: string + updatedAt: number + sessionId: string + agentId: string + kind: string + source: OpenClawSessionSource + status?: string + totalTokens?: number + model?: string + modelProvider?: string +} + +export interface BrowserOSOpenClawAgentSessionResponse { + agentId: string + exists: boolean + sessionKey: string | null + session: BrowserOSOpenClawSession | null +} + +export interface BrowserOSChatHistoryItem { + id: string + role: 'user' | 'assistant' + text: string + timestamp?: number + messageSeq: number + sessionKey: string + source: OpenClawSessionSource +} + +export interface BrowserOSOpenClawHistoryPageResponse { + agentId: string + sessionKey: string | null + session: BrowserOSOpenClawSession | null + items: BrowserOSChatHistoryItem[] + page: { + cursor?: string + hasMore: boolean + limit: number + } +} + +interface HistoryPageInput { + sessionKey?: string + cursor?: string + limit?: number +} + +function normalizeHistoryLimit(limit?: number): number { + if (limit === undefined || !Number.isFinite(limit)) return 50 + return Math.max(1, Math.min(100, Math.trunc(limit))) +} + +function toBrowserOSSession( + session: OpenClawSessionEntry, +): BrowserOSOpenClawSession { + return { + ...session, + source: classifySessionSource(session.key), + } +} + +function classifySessionSource(key: string): OpenClawSessionSource { + if (key.includes(':cron:')) return 'cron' + if (key.includes(':hook:')) return 'hook' + if (key.includes('openai-user:browseros')) return 'user-chat' + if (key.includes('qa-channel')) return 'channel' + return 'other' +} + +function filterOpenClawSystemMessages( + messages: OpenClawChatMessage[], +): OpenClawChatMessage[] { + const result: OpenClawChatMessage[] = [] + + for (const message of messages) { + const text = getTextContent(message).trim() + + if (message.role === 'assistant' && text.startsWith('HEARTBEAT')) continue + if ( + message.role === 'user' && + text.includes('Handle this reminder internally') + ) { + continue + } + + if ( + message.role === 'user' && + text.startsWith('[Chat messages since your last reply') + ) { + const marker = '[Current message - respond to this]' + const index = text.indexOf(marker) + if (index >= 0) { + const actual = text + .slice(index + marker.length) + .trim() + .replace(/^User:\s*/i, '') + if (actual) { + result.push({ + ...message, + content: [{ type: 'text', text: actual }], + }) + } + } + continue + } + + result.push(message) + } + + return result +} + +function normalizeChatHistoryMessages(input: { + sessionKey: string + source: OpenClawSessionSource + messages: OpenClawChatMessage[] +}): BrowserOSChatHistoryItem[] { + return input.messages + .map((message, index): BrowserOSChatHistoryItem | null => { + if (message.role !== 'user' && message.role !== 'assistant') return null + const text = getTextContent(message).trim() + if (!text) return null + + return { + id: `${input.sessionKey}:${index}`, + role: message.role, + text, + timestamp: message.timestamp, + messageSeq: index, + sessionKey: input.sessionKey, + source: input.source, + } + }) + .filter((item): item is BrowserOSChatHistoryItem => item !== null) +} + +function getTextContent(message: OpenClawChatMessage): string { + return message.content + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join('') +} + +function encodeHistoryCursor(input: { + sessionKey: string + end: number +}): string { + return Buffer.from(JSON.stringify(input), 'utf-8').toString('base64url') +} + +function decodeHistoryCursor( + cursor?: string, +): { sessionKey: string; end: number } | null { + if (!cursor) return null + try { + const parsed = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf-8'), + ) as { + sessionKey?: unknown + end?: unknown + } + if (typeof parsed.sessionKey !== 'string') return null + if (typeof parsed.end !== 'number' || !Number.isFinite(parsed.end)) { + return null + } + return { + sessionKey: parsed.sessionKey, + end: Math.max(0, Math.trunc(parsed.end)), + } + } catch { + return null + } +} + export class OpenClawService { private runtime: ContainerRuntime private cliClient: OpenClawCliClient @@ -573,6 +757,87 @@ export class OpenClawService { return this.runControlPlaneCall(() => this.cliClient.listAgents()) } + async listSessions(agentId?: string): Promise { + logger.debug('Listing OpenClaw sessions', { agentId }) + const sessions = await this.cliClient.listSessions(agentId) + return sessions + .map(toBrowserOSSession) + .sort((a, b) => b.updatedAt - a.updatedAt) + } + + async resolveAgentSession( + agentId: string, + ): Promise { + const sessions = await this.listSessions(agentId) + const session = + sessions.find((entry) => entry.source === 'user-chat') ?? + sessions.find((entry) => entry.kind.toLowerCase().includes('chat')) ?? + sessions[0] ?? + null + + return { + agentId, + exists: !!session, + sessionKey: session?.key ?? null, + session, + } + } + + async getChatHistory(sessionKey: string): Promise { + await this.assertGatewayReady() + logger.debug('Fetching OpenClaw chat history', { sessionKey }) + return this.runControlPlaneCall(() => + this.cliClient.getChatHistory(sessionKey), + ) + } + + async getAgentHistoryPage( + agentId: string, + input: HistoryPageInput = {}, + ): Promise { + const limit = normalizeHistoryLimit(input.limit) + const cursor = decodeHistoryCursor(input.cursor) + const resolved = input.sessionKey + ? await this.resolveSpecificAgentSession(agentId, input.sessionKey) + : await this.resolveAgentSession(agentId) + + const session = resolved.session + if (!session) { + return { + agentId, + sessionKey: null, + session: null, + items: [], + page: { hasMore: false, limit }, + } + } + + const sessionKey = cursor?.sessionKey ?? session.key + const rawMessages = await this.getChatHistory(sessionKey) + const items = normalizeChatHistoryMessages({ + sessionKey, + source: session.source, + messages: filterOpenClawSystemMessages(rawMessages), + }) + const end = Math.min(cursor?.end ?? items.length, items.length) + const start = Math.max(0, end - limit) + const pageItems = items.slice(start, end) + const nextCursor = + start > 0 ? encodeHistoryCursor({ sessionKey, end: start }) : undefined + + return { + agentId, + sessionKey, + session, + items: pageItems, + page: { + cursor: nextCursor, + hasMore: start > 0, + limit, + }, + } + } + // ── Chat Stream (HTTP) ─────────────────────────────────────────────── async chatStream( @@ -598,6 +863,29 @@ export class OpenClawService { ) } + private async resolveSpecificAgentSession( + agentId: string, + sessionKey: string, + ): Promise { + const sessions = await this.listSessions(agentId) + const session = + sessions.find((entry) => entry.key === sessionKey) ?? + toBrowserOSSession({ + key: sessionKey, + updatedAt: 0, + sessionId: '', + agentId, + kind: '', + }) + + return { + agentId, + exists: true, + sessionKey, + session, + } + } + // ── Podman Overrides ───────────────────────────────────────────────── async applyPodmanOverrides(input: { diff --git a/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts index 47c0c8778..ffba6360d 100644 --- a/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/routes/openclaw.test.ts @@ -264,6 +264,180 @@ describe('createOpenClawRoutes', () => { expect(response.status).toBe(404) }) + it('returns OpenClaw sessions for an agent', async () => { + const actualOpenClawService = await import( + '../../../src/api/services/openclaw/openclaw-service' + ) + const listSessions = mock(async () => [ + { + key: 'openai-user:browseros:main:session-1', + updatedAt: 20, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + source: 'user-chat', + }, + ]) + + mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({ + ...actualOpenClawService, + getOpenClawService: () => ({ listSessions }) as never, + })) + + const { createOpenClawRoutes } = await import( + '../../../src/api/routes/openclaw' + ) + const route = createOpenClawRoutes() + + const response = await route.request('/agents/main/sessions?limit=1') + + expect(response.status).toBe(200) + expect(listSessions).toHaveBeenCalledWith('main') + expect(await response.json()).toEqual({ + agentId: 'main', + sessions: [ + { + key: 'openai-user:browseros:main:session-1', + updatedAt: 20, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + source: 'user-chat', + }, + ], + }) + }) + + it('returns the resolved active OpenClaw session for an agent', async () => { + const actualOpenClawService = await import( + '../../../src/api/services/openclaw/openclaw-service' + ) + const resolveAgentSession = mock(async () => ({ + agentId: 'main', + exists: true, + sessionKey: 'session-1', + session: { + key: 'session-1', + updatedAt: 20, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + source: 'other', + }, + })) + + mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({ + ...actualOpenClawService, + getOpenClawService: () => ({ resolveAgentSession }) as never, + })) + + const { createOpenClawRoutes } = await import( + '../../../src/api/routes/openclaw' + ) + const route = createOpenClawRoutes() + + const response = await route.request('/agents/main/session') + + expect(response.status).toBe(200) + expect(resolveAgentSession).toHaveBeenCalledWith('main') + expect(await response.json()).toEqual({ + agentId: 'main', + exists: true, + sessionKey: 'session-1', + session: { + key: 'session-1', + updatedAt: 20, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + source: 'other', + }, + }) + }) + + it('returns a normalized OpenClaw history page for an agent', async () => { + const actualOpenClawService = await import( + '../../../src/api/services/openclaw/openclaw-service' + ) + const getAgentHistoryPage = mock(async () => ({ + agentId: 'main', + sessionKey: 'session-1', + session: { + key: 'session-1', + updatedAt: 20, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + source: 'other', + }, + items: [ + { + id: 'session-1:0', + role: 'user', + text: 'Hello', + timestamp: 1, + messageSeq: 0, + sessionKey: 'session-1', + source: 'other', + }, + ], + page: { + cursor: 'older-cursor', + hasMore: true, + limit: 25, + }, + })) + + mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({ + ...actualOpenClawService, + getOpenClawService: () => ({ getAgentHistoryPage }) as never, + })) + + const { createOpenClawRoutes } = await import( + '../../../src/api/routes/openclaw' + ) + const route = createOpenClawRoutes() + + const response = await route.request( + '/agents/main/history?sessionKey=session-1&cursor=abc&limit=25', + ) + + expect(response.status).toBe(200) + expect(getAgentHistoryPage).toHaveBeenCalledWith('main', { + sessionKey: 'session-1', + cursor: 'abc', + limit: 25, + }) + expect(await response.json()).toEqual({ + agentId: 'main', + sessionKey: 'session-1', + session: { + key: 'session-1', + updatedAt: 20, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + source: 'other', + }, + items: [ + { + id: 'session-1:0', + role: 'user', + text: 'Hello', + timestamp: 1, + messageSeq: 0, + sessionKey: 'session-1', + source: 'other', + }, + ], + page: { + cursor: 'older-cursor', + hasMore: true, + limit: 25, + }, + }) + }) + it('returns the current podman overrides on GET', async () => { const actualOpenClawService = await import( '../../../src/api/services/openclaw/openclaw-service' diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts index 17daf1d78..ec3788c3c 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-cli-client.test.ts @@ -264,6 +264,109 @@ describe('OpenClawCliClient', () => { await expect(client.listAgents()).rejects.toThrow('agent already exists') }) + it('lists sessions for a specific agent', async () => { + const execInContainer = mock( + async (command: string[], onLog?: (line: string) => void) => { + expect(command).toEqual([ + 'node', + 'dist/index.js', + 'sessions', + '--json', + '--agent', + 'main', + ]) + onLog?.( + JSON.stringify({ + sessions: [ + { + key: 'openai-user:browseros:main:session-1', + updatedAt: 1710000000000, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + status: 'active', + totalTokens: 120, + model: 'openai/gpt-5.4-mini', + modelProvider: 'openai', + }, + ], + count: 1, + }), + ) + return 0 + }, + ) + + const client = new OpenClawCliClient({ execInContainer }) + const sessions = await client.listSessions('main') + + expect(sessions).toEqual([ + { + key: 'openai-user:browseros:main:session-1', + updatedAt: 1710000000000, + sessionId: 'session-1', + agentId: 'main', + kind: 'chat', + status: 'active', + totalTokens: 120, + model: 'openai/gpt-5.4-mini', + modelProvider: 'openai', + }, + ]) + }) + + it('fetches chat history through the OpenClaw gateway call command', async () => { + const execInContainer = mock( + async (command: string[], onLog?: (line: string) => void) => { + expect(command).toEqual([ + 'node', + 'dist/index.js', + 'gateway', + 'call', + 'chat.history', + '--params', + '{"sessionKey":"session-1"}', + '--json', + ]) + onLog?.( + JSON.stringify({ + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + timestamp: 1710000000001, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hi there' }], + timestamp: 1710000000002, + usage: { input: 5, output: 6 }, + }, + ], + }), + ) + return 0 + }, + ) + + const client = new OpenClawCliClient({ execInContainer }) + const history = await client.getChatHistory('session-1') + + expect(history).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + timestamp: 1710000000001, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hi there' }], + timestamp: 1710000000002, + usage: { input: 5, output: 6 }, + }, + ]) + }) + it('parses config get output from mixed logs and pretty-printed JSON', async () => { const execInContainer = mock( async (command: string[], onLog?: (line: string) => void) => { diff --git a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts index d9470a31d..8402a196c 100644 --- a/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts +++ b/packages/browseros-agent/apps/server/tests/api/services/openclaw/openclaw-service.test.ts @@ -47,7 +47,9 @@ type MutableOpenClawService = OpenClawService & { probe?: ReturnType createAgent?: ReturnType getConfig?: ReturnType + getChatHistory?: ReturnType listAgents?: ReturnType + listSessions?: ReturnType setDefaultModel?: ReturnType } bootstrapCliClient: { @@ -147,6 +149,118 @@ describe('OpenClawService', () => { ]) }) + it('resolves the latest user-chat session for an agent', async () => { + const service = new OpenClawService() as MutableOpenClawService + + service.cliClient = { + listSessions: mock(async () => [ + { + key: 'agent:main:cron:daily', + updatedAt: 30, + sessionId: 'cron-session', + agentId: 'main', + kind: 'cron', + }, + { + key: 'openai-user:browseros:main:chat-session', + updatedAt: 20, + sessionId: 'chat-session', + agentId: 'main', + kind: 'chat', + }, + ]), + } + + await expect(service.resolveAgentSession('main')).resolves.toEqual({ + agentId: 'main', + exists: true, + sessionKey: 'openai-user:browseros:main:chat-session', + session: { + key: 'openai-user:browseros:main:chat-session', + updatedAt: 20, + sessionId: 'chat-session', + agentId: 'main', + kind: 'chat', + source: 'user-chat', + }, + }) + }) + + it('returns normalized paginated chat history for an agent session', async () => { + const service = new OpenClawService() as MutableOpenClawService + + service.runtime = { + isReady: async () => true, + } + service.cliClient = { + listSessions: mock(async () => [ + { + key: 'openai-user:browseros:main:chat-session', + updatedAt: 20, + sessionId: 'chat-session', + agentId: 'main', + kind: 'chat', + }, + ]), + getChatHistory: mock(async () => [ + { + role: 'assistant', + content: [{ type: 'text', text: 'HEARTBEAT_OK' }], + }, + { + role: 'user', + content: [{ type: 'text', text: 'First question' }], + timestamp: 1, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'First answer' }], + timestamp: 2, + }, + { + role: 'user', + content: [ + { + type: 'text', + text: + '[Chat messages since your last reply]\n' + + '[Current message - respond to this]\n' + + 'User: Second question', + }, + ], + timestamp: 3, + }, + ]), + } + + const page = await service.getAgentHistoryPage('main', { limit: 2 }) + + expect(page.agentId).toBe('main') + expect(page.sessionKey).toBe('openai-user:browseros:main:chat-session') + expect(page.items).toEqual([ + { + id: 'openai-user:browseros:main:chat-session:1', + role: 'assistant', + text: 'First answer', + timestamp: 2, + messageSeq: 1, + sessionKey: 'openai-user:browseros:main:chat-session', + source: 'user-chat', + }, + { + id: 'openai-user:browseros:main:chat-session:2', + role: 'user', + text: 'Second question', + timestamp: 3, + messageSeq: 2, + sessionKey: 'openai-user:browseros:main:chat-session', + source: 'user-chat', + }, + ]) + expect(page.page.hasMore).toBe(true) + expect(typeof page.page.cursor).toBe('string') + }) + it('maps successful cli client probes into connected status', async () => { tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-')) await mkdir(join(tempDir, '.openclaw'), { recursive: true })