mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-20 20:39:10 +00:00
feat: add openclaw chat history APIs
This commit is contained in:
@@ -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<{
|
||||
|
||||
@@ -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<OpenClawSessionEntry[]> {
|
||||
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<OpenClawChatMessage[]> {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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<BrowserOSOpenClawSession[]> {
|
||||
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<BrowserOSOpenClawAgentSessionResponse> {
|
||||
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<OpenClawChatMessage[]> {
|
||||
await this.assertGatewayReady()
|
||||
logger.debug('Fetching OpenClaw chat history', { sessionKey })
|
||||
return this.runControlPlaneCall(() =>
|
||||
this.cliClient.getChatHistory(sessionKey),
|
||||
)
|
||||
}
|
||||
|
||||
async getAgentHistoryPage(
|
||||
agentId: string,
|
||||
input: HistoryPageInput = {},
|
||||
): Promise<BrowserOSOpenClawHistoryPageResponse> {
|
||||
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<BrowserOSOpenClawAgentSessionResponse> {
|
||||
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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -47,7 +47,9 @@ type MutableOpenClawService = OpenClawService & {
|
||||
probe?: ReturnType<typeof mock>
|
||||
createAgent?: ReturnType<typeof mock>
|
||||
getConfig?: ReturnType<typeof mock>
|
||||
getChatHistory?: ReturnType<typeof mock>
|
||||
listAgents?: ReturnType<typeof mock>
|
||||
listSessions?: ReturnType<typeof mock>
|
||||
setDefaultModel?: ReturnType<typeof mock>
|
||||
}
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user