Compare commits

...

2 Commits

Author SHA1 Message Date
Nikhil Sonti
830f15d24e fix: address ACP history review comments 2026-04-28 16:34:10 -07:00
Nikhil Sonti
1420495ea9 fix: load ACP harness history from ACPX 2026-04-28 16:32:16 -07:00
13 changed files with 556 additions and 434 deletions

View File

@@ -23,9 +23,9 @@ export interface BrowserOSChatHistoryToolCall {
toolName: string
label: string
subject?: string
status: 'completed' | 'failed'
input?: Record<string, unknown>
output?: string
status: 'pending' | 'running' | 'completed' | 'failed'
input?: unknown
output?: unknown
error?: string
durationMs?: number
}

View File

@@ -0,0 +1,71 @@
import { buildToolLabel } from '../../../lib/tool-labels'
import type { HarnessAgentHistoryPage } from '../agents/agent-harness-types'
import type {
AgentHistoryPageResponse,
BrowserOSChatHistoryItem,
BrowserOSChatHistoryToolCall,
} from './claw-chat-types'
export function mapHarnessHistoryPage(
page: HarnessAgentHistoryPage,
): AgentHistoryPageResponse {
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => {
const toolCalls = item.toolCalls?.map(
(tool): BrowserOSChatHistoryToolCall => {
const input = asRecord(tool.input)
const { label, subject } = buildToolLabel(tool.toolName, input)
return {
toolName: tool.toolName,
label,
status: tool.status,
...(tool.toolCallId ? { toolCallId: tool.toolCallId } : {}),
...(subject ? { subject } : {}),
...(tool.input !== undefined ? { input: tool.input } : {}),
...(tool.output !== undefined ? { output: tool.output } : {}),
...(tool.error ? { error: tool.error } : {}),
...(tool.durationMs != null ? { durationMs: tool.durationMs } : {}),
}
},
)
return {
id: item.id,
role: item.role,
text: item.text,
timestamp: item.createdAt,
messageSeq: index + 1,
sessionKey: 'main',
source: 'user-chat',
...(item.reasoning ? { reasoning: item.reasoning } : {}),
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
}
})
const updatedAt =
page.items.length > 0
? Math.max(...page.items.map((item) => item.createdAt))
: Date.now()
return {
agentId: page.agentId,
sessionKey: 'main',
session: {
key: 'main',
updatedAt,
sessionId: 'main',
agentId: page.agentId,
kind: 'agent-harness',
source: 'user-chat',
},
items,
page: {
hasMore: false,
limit: items.length,
},
}
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'bun:test'
import { mapHarnessHistoryPage } from './harness-history-mapper'
describe('mapHarnessHistoryPage', () => {
it('maps rich harness history into chat history items', () => {
const page = mapHarnessHistoryPage({
agentId: 'agent-1',
sessionId: 'main',
items: [
{
id: 'agent:agent-1:main:1',
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'Done.',
createdAt: 1000,
reasoning: { text: 'checking state' },
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'read_file',
status: 'completed',
input: { path: 'src/index.ts' },
output: 'file contents',
},
],
},
],
})
expect(page.items).toEqual([
{
id: 'agent:agent-1:main:1',
role: 'assistant',
text: 'Done.',
timestamp: 1000,
messageSeq: 1,
sessionKey: 'main',
source: 'user-chat',
reasoning: { text: 'checking state' },
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'read_file',
label: 'Read file',
subject: 'index.ts',
status: 'completed',
input: { path: 'src/index.ts' },
output: 'file contents',
},
],
},
])
})
})

View File

@@ -1,11 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import type { HarnessAgentHistoryPage } from '@/entrypoints/app/agents/agent-harness-types'
import { fetchHarnessAgentHistory } from '@/entrypoints/app/agents/useAgents'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
import type {
AgentHistoryPageResponse,
BrowserOSChatHistoryItem,
} from './claw-chat-types'
import type { AgentHistoryPageResponse } from './claw-chat-types'
import { mapHarnessHistoryPage } from './harness-history-mapper'
const HISTORY_QUERY_KEY = 'harness-agent-history'
@@ -30,39 +27,3 @@ export function useHarnessChatHistory(agentId: string, enabled = true) {
isLoading: query.isLoading || urlLoading,
}
}
function mapHarnessHistoryPage(
page: HarnessAgentHistoryPage,
): AgentHistoryPageResponse {
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => ({
id: item.id,
role: item.role,
text: item.text,
timestamp: item.createdAt,
messageSeq: index + 1,
sessionKey: 'main',
source: 'user-chat',
}))
const updatedAt =
page.items.length > 0
? Math.max(...page.items.map((item) => item.createdAt))
: Date.now()
return {
agentId: page.agentId,
sessionKey: 'main',
session: {
key: 'main',
updatedAt,
sessionId: 'main',
agentId: page.agentId,
kind: 'agent-harness',
source: 'user-chat',
},
items,
page: {
hasMore: false,
limit: items.length,
},
}
}

View File

@@ -62,19 +62,36 @@ export interface CreateHarnessAgentInput {
reasoningEffort?: string
}
export interface HarnessTranscriptEntry {
export interface HarnessHistoryReasoning {
text: string
durationMs?: number
}
export interface HarnessHistoryToolCall {
toolCallId?: string
toolName: string
status: 'pending' | 'running' | 'completed' | 'failed'
input?: unknown
output?: unknown
error?: string
durationMs?: number
}
export interface HarnessHistoryEntry {
id: string
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
createdAt: number
reasoning?: HarnessHistoryReasoning
toolCalls?: HarnessHistoryToolCall[]
}
export interface HarnessAgentHistoryPage {
agentId: string
sessionId: 'main'
items: HarnessTranscriptEntry[]
items: HarnessHistoryEntry[]
}
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {

View File

@@ -10,7 +10,6 @@ import {
type CreateAgentInput,
FileAgentStore,
} from '../../../lib/agents/file-agent-store'
import { FileTranscriptStore } from '../../../lib/agents/file-transcript-store'
import type {
AgentHistoryPage,
AgentRuntime,
@@ -19,19 +18,16 @@ import type {
export class AgentHarnessService {
private readonly agentStore: FileAgentStore
private readonly transcriptStore: FileTranscriptStore
private readonly runtime: AgentRuntime
constructor(
deps: {
agentStore?: FileAgentStore
transcriptStore?: FileTranscriptStore
runtime?: AgentRuntime
browserosServerPort?: number
} = {},
) {
this.agentStore = deps.agentStore ?? new FileAgentStore()
this.transcriptStore = deps.transcriptStore ?? new FileTranscriptStore()
this.runtime =
deps.runtime ??
new AcpxRuntime({ browserosServerPort: deps.browserosServerPort })
@@ -55,14 +51,7 @@ export class AgentHarnessService {
async getHistory(agentId: string): Promise<AgentHistoryPage> {
const agent = await this.requireAgent(agentId)
return {
agentId: agent.id,
sessionId: 'main',
items: await this.transcriptStore.list({
agentId: agent.id,
sessionId: 'main',
}),
}
return this.runtime.getHistory({ agent, sessionId: 'main' })
}
async send(input: {
@@ -71,13 +60,7 @@ export class AgentHarnessService {
signal?: AbortSignal
}): Promise<ReadableStream<AgentStreamEvent>> {
const agent = await this.requireAgent(input.agentId)
await this.transcriptStore.append({
agentId: agent.id,
sessionId: 'main',
role: 'user',
text: input.message,
})
const runtimeStream = await this.runtime.send({
return this.runtime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
@@ -85,7 +68,6 @@ export class AgentHarnessService {
permissionMode: agent.permissionMode,
signal: input.signal,
})
return this.persistAssistantTranscript(agent, runtimeStream)
}
private async requireAgent(agentId: string): Promise<AgentDefinition> {
@@ -95,54 +77,6 @@ export class AgentHarnessService {
}
return agent
}
private persistAssistantTranscript(
agent: AgentDefinition,
stream: ReadableStream<AgentStreamEvent>,
): ReadableStream<AgentStreamEvent> {
let reader: ReadableStreamDefaultReader<AgentStreamEvent> | null = null
let assistantText = ''
let transcriptFlushed = false
const flushAssistantTranscript = async () => {
if (transcriptFlushed || !assistantText.trim()) return
transcriptFlushed = true
await this.transcriptStore.append({
agentId: agent.id,
sessionId: 'main',
role: 'assistant',
text: assistantText,
})
}
return new ReadableStream<AgentStreamEvent>({
start: async (controller) => {
reader = stream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value.type === 'text_delta' && value.stream === 'output') {
assistantText += value.text
} else if (value.type === 'done' && !assistantText && value.text) {
assistantText = value.text
}
controller.enqueue(value)
}
await flushAssistantTranscript()
controller.close()
} catch (err) {
controller.error(err)
} finally {
reader?.releaseLock()
}
},
cancel: async () => {
await flushAssistantTranscript()
await reader?.cancel('BrowserOS stream cancelled')
},
})
}
}
export class UnknownAgentError extends Error {

View File

@@ -12,6 +12,7 @@ import {
type AcpRuntimeOptions,
type AcpRuntimeTurn,
type AcpRuntimeTurnResult,
type AcpSessionRecord,
type AcpRuntime as AcpxCoreRuntime,
createAcpRuntime,
createAgentRegistry,
@@ -19,6 +20,11 @@ import {
} from 'acpx/runtime'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import type {
AgentDefinition,
AgentHistoryEntry,
AgentHistoryToolCall,
} from './agent-types'
import type {
AgentHistoryPage,
AgentPromptInput,
@@ -48,6 +54,7 @@ export class AcpxRuntime implements AgentRuntime {
private readonly runtimeFactory: (
options: AcpRuntimeOptions,
) => AcpxCoreRuntime
private readonly sessionStore: ReturnType<typeof createRuntimeStore>
private readonly runtimes = new Map<string, AcpxCoreRuntime>()
constructor(options: AcpxRuntimeOptions = {}) {
@@ -58,6 +65,7 @@ export class AcpxRuntime implements AgentRuntime {
join(getBrowserosDir(), 'agents', 'acpx')
this.browserosServerPort =
options.browserosServerPort ?? DEFAULT_PORTS.server
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
}
@@ -75,7 +83,11 @@ export class AcpxRuntime implements AgentRuntime {
agent: AgentPromptInput['agent']
sessionId: 'main'
}): Promise<AgentHistoryPage> {
return { agentId: input.agent.id, sessionId: input.sessionId, items: [] }
const record = await this.sessionStore.load(input.agent.sessionKey)
if (!record) {
return { agentId: input.agent.id, sessionId: input.sessionId, items: [] }
}
return mapAcpxSessionRecordToHistory(input.agent, input.sessionId, record)
}
async send(
@@ -113,7 +125,7 @@ export class AcpxRuntime implements AgentRuntime {
const runtime = this.runtimeFactory({
cwd: input.cwd,
sessionStore: createRuntimeStore({ stateDir: this.stateDir }),
sessionStore: this.sessionStore,
agentRegistry: createBrowserosAgentRegistry(input.permissionMode),
mcpServers: createBrowserosMcpServers(this.browserosServerPort),
permissionMode: input.permissionMode,
@@ -131,6 +143,165 @@ export class AcpxRuntime implements AgentRuntime {
}
}
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
type AcpxUserContent = Extract<
Exclude<AcpxSessionMessage, 'Resume'>,
{ User: unknown }
>['User']['content'][number]
type AcpxAgentMessage = Extract<
Exclude<AcpxSessionMessage, 'Resume'>,
{ Agent: unknown }
>['Agent']
type AcpxAgentContent = AcpxAgentMessage['content'][number]
type AcpxToolUse = Extract<AcpxAgentContent, { ToolUse: unknown }>['ToolUse']
type AcpxToolResult = AcpxAgentMessage['tool_results'][string]
function mapAcpxSessionRecordToHistory(
agent: AgentDefinition,
sessionId: 'main',
record: AcpSessionRecord,
): AgentHistoryPage {
const createdAt = parseRecordTimestamp(record)
const items = record.messages.flatMap(
(message, index): AgentHistoryEntry[] => {
if (message === 'Resume') return []
const id = `${record.acpxRecordId}:${index}`
const messageCreatedAt = createdAt + index
if ('User' in message) {
const text = message.User.content
.map(userContentToText)
.filter(Boolean)
.join('\n\n')
.trim()
if (!text) return []
return [
{
id,
agentId: agent.id,
sessionId,
role: 'user',
text,
createdAt: messageCreatedAt,
},
]
}
const entry = mapAgentMessageToHistoryEntry({
id,
agentId: agent.id,
sessionId,
createdAt: messageCreatedAt,
message: message.Agent,
})
return entry ? [entry] : []
},
)
return {
agentId: agent.id,
sessionId,
items,
}
}
function mapAgentMessageToHistoryEntry(input: {
id: string
agentId: string
sessionId: 'main'
createdAt: number
message: AcpxAgentMessage
}): AgentHistoryEntry | null {
const textParts: string[] = []
const reasoningParts: string[] = []
const toolCalls: AgentHistoryToolCall[] = []
for (const content of input.message.content) {
if ('Text' in content) {
textParts.push(content.Text)
} else if ('Thinking' in content) {
reasoningParts.push(content.Thinking.text)
} else if ('RedactedThinking' in content) {
reasoningParts.push('[redacted_thinking]')
} else if ('ToolUse' in content) {
toolCalls.push(
mapToolUseToHistoryToolCall(
content.ToolUse,
input.message.tool_results[content.ToolUse.id],
),
)
}
}
const text = textParts.join('').trim()
const reasoningText = reasoningParts.join('\n\n').trim()
if (!text && !reasoningText && toolCalls.length === 0) return null
return {
id: input.id,
agentId: input.agentId,
sessionId: input.sessionId,
role: 'assistant',
text,
createdAt: input.createdAt,
...(reasoningText ? { reasoning: { text: reasoningText } } : {}),
...(toolCalls.length > 0 ? { toolCalls } : {}),
}
}
function mapToolUseToHistoryToolCall(
tool: AcpxToolUse,
result: AcpxToolResult | undefined,
): AgentHistoryToolCall {
const resultValue = result ? toolResultValue(result) : undefined
const status = result?.is_error
? 'failed'
: result || tool.is_input_complete
? 'completed'
: 'running'
return {
toolCallId: tool.id,
toolName: result?.tool_name ?? tool.name,
status,
input: tool.input,
...(result?.is_error
? { error: stringifyToolError(resultValue) }
: resultValue !== undefined
? { output: resultValue }
: {}),
}
}
function userContentToText(content: AcpxUserContent): string {
if ('Text' in content) return content.Text
if ('Mention' in content) return content.Mention.content
if ('Image' in content) return content.Image.source ? '[image]' : ''
return ''
}
function toolResultValue(result: AcpxToolResult): unknown {
if (result.output != null) return result.output
if ('Text' in result.content) return result.content.Text
if ('Image' in result.content) return result.content.Image.source
return undefined
}
function stringifyToolError(value: unknown): string {
if (typeof value === 'string') return value
if (value === undefined) return 'Tool call failed'
try {
return JSON.stringify(value)
} catch {
return 'Tool call failed'
}
}
function parseRecordTimestamp(record: AcpSessionRecord): number {
const parsed = Date.parse(record.updated_at || record.lastUsedAt)
return Number.isFinite(parsed) ? parsed : 0
}
function createAcpxEventStream(
runtime: AcpxCoreRuntime,
input: AgentPromptInput,

View File

@@ -38,11 +38,28 @@ export interface AgentAdapterDescriptor {
}>
}
export interface AgentTranscriptEntry {
export interface AgentHistoryReasoning {
text: string
durationMs?: number
}
export interface AgentHistoryToolCall {
toolCallId?: string
toolName: string
status: 'pending' | 'running' | 'completed' | 'failed'
input?: unknown
output?: unknown
error?: string
durationMs?: number
}
export interface AgentHistoryEntry {
id: string
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
createdAt: number
reasoning?: AgentHistoryReasoning
toolCalls?: AgentHistoryToolCall[]
}

View File

@@ -1,115 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import { appendFile, mkdir, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import type { AgentTranscriptEntry } from './agent-types'
export interface TranscriptListInput {
agentId: string
sessionId: 'main'
}
export interface TranscriptAppendInput {
agentId: string
sessionId: 'main'
role: 'user' | 'assistant'
text: string
}
export class FileTranscriptStore {
private readonly rootDir: string
constructor(options: { rootDir?: string } = {}) {
this.rootDir =
options.rootDir ??
join(getBrowserosDir(), 'agents', 'harness', 'transcripts')
}
async append(input: TranscriptAppendInput): Promise<AgentTranscriptEntry> {
const entry: AgentTranscriptEntry = {
id: randomUUID(),
agentId: input.agentId,
sessionId: input.sessionId,
role: input.role,
text: input.text,
createdAt: Date.now(),
}
const filePath = this.pathFor(input)
await mkdir(dirname(filePath), { recursive: true })
await appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8')
logger.debug('Agent harness transcript appended entry', {
agentId: entry.agentId,
sessionId: entry.sessionId,
role: entry.role,
textLength: entry.text.length,
filePath,
})
return entry
}
async list(input: TranscriptListInput): Promise<AgentTranscriptEntry[]> {
try {
const raw = await readFile(this.pathFor(input), 'utf8')
const entries = raw
.split('\n')
.filter(Boolean)
.map((line) => this.parseLine(line, input))
.filter((entry): entry is AgentTranscriptEntry => entry !== null)
.sort((a, b) => a.createdAt - b.createdAt)
logger.debug('Agent harness transcript listed entries', {
agentId: input.agentId,
sessionId: input.sessionId,
count: entries.length,
filePath: this.pathFor(input),
})
return entries
} catch (err) {
if (isNotFoundError(err)) {
logger.debug('Agent harness transcript file missing', {
agentId: input.agentId,
sessionId: input.sessionId,
filePath: this.pathFor(input),
})
return []
}
throw err
}
}
private pathFor(input: TranscriptListInput): string {
return join(this.rootDir, input.agentId, `${input.sessionId}.jsonl`)
}
private parseLine(
line: string,
input: TranscriptListInput,
): AgentTranscriptEntry | null {
try {
return JSON.parse(line) as AgentTranscriptEntry
} catch (err) {
logger.warn('Agent harness transcript skipped malformed line', {
agentId: input.agentId,
sessionId: input.sessionId,
filePath: this.pathFor(input),
error: err instanceof Error ? err.message : String(err),
})
return null
}
}
}
function isNotFoundError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ENOENT'
)
}

View File

@@ -6,8 +6,8 @@
import type {
AgentDefinition,
AgentHistoryEntry,
AgentPermissionMode,
AgentTranscriptEntry,
} from './agent-types'
export interface AgentStatus {
@@ -24,7 +24,7 @@ export interface AgentSession {
export interface AgentHistoryPage {
agentId: string
sessionId: 'main'
items: AgentTranscriptEntry[]
items: AgentHistoryEntry[]
}
export type AgentStreamEvent =

View File

@@ -5,12 +5,8 @@
import { describe, expect, it } from 'bun:test'
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
import type {
AgentDefinition,
AgentTranscriptEntry,
} from '../../../../src/lib/agents/agent-types'
import type { AgentDefinition } from '../../../../src/lib/agents/agent-types'
import type { FileAgentStore } from '../../../../src/lib/agents/file-agent-store'
import type { FileTranscriptStore } from '../../../../src/lib/agents/file-transcript-store'
import type {
AgentRuntime,
AgentStreamEvent,
@@ -19,48 +15,8 @@ import type {
describe('AgentHarnessService', () => {
it('creates named agents and sends prompts through the main session', async () => {
const agents: AgentDefinition[] = []
const transcripts: AgentTranscriptEntry[] = []
const runtimeInputs: unknown[] = []
const agentStore = {
async list() {
return agents
},
async get(id: string) {
return agents.find((agent) => agent.id === id) ?? null
},
async create(input) {
const agent: AgentDefinition = {
id: 'agent-1',
name: input.name,
adapter: input.adapter,
modelId: input.modelId,
reasoningEffort: input.reasoningEffort,
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
agents.push(agent)
return agent
},
async delete() {
return true
},
} satisfies Partial<FileAgentStore>
const transcriptStore = {
async append(input) {
const entry: AgentTranscriptEntry = {
id: String(transcripts.length + 1),
createdAt: 1000 + transcripts.length,
...input,
}
transcripts.push(entry)
return entry
},
async list() {
return transcripts
},
} satisfies Partial<FileTranscriptStore>
const agentStore = createAgentStore(agents)
const runtime: AgentRuntime = {
async status() {
return { state: 'ready' }
@@ -89,7 +45,6 @@ describe('AgentHarnessService', () => {
const service = new AgentHarnessService({
agentStore: agentStore as FileAgentStore,
transcriptStore: transcriptStore as FileTranscriptStore,
runtime,
})
@@ -99,11 +54,12 @@ describe('AgentHarnessService', () => {
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
})
const stream = await service.send({
agentId: agent.id,
message: 'hello',
})
await stream.pipeTo(new WritableStream())
const events = await collectStream(
await service.send({
agentId: agent.id,
message: 'hello',
}),
)
expect(runtimeInputs[0]).toMatchObject({
agent,
@@ -112,13 +68,13 @@ describe('AgentHarnessService', () => {
message: 'hello',
permissionMode: 'approve-all',
})
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'answer' },
expect(events).toEqual([
{ type: 'text_delta', text: 'answer', stream: 'output' },
{ type: 'done', stopReason: 'end_turn' },
])
})
it('flushes partial assistant text when the response stream is cancelled', async () => {
it('reads history from the runtime', async () => {
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
@@ -130,35 +86,7 @@ describe('AgentHarnessService', () => {
createdAt: 1000,
updatedAt: 1000,
}
const transcripts: AgentTranscriptEntry[] = []
const agentStore = {
async list() {
return [agent]
},
async get(id: string) {
return id === agent.id ? agent : null
},
async create() {
return agent
},
async delete() {
return true
},
} satisfies Partial<FileAgentStore>
const transcriptStore = {
async append(input) {
const entry: AgentTranscriptEntry = {
id: String(transcripts.length + 1),
createdAt: 1000 + transcripts.length,
...input,
}
transcripts.push(entry)
return entry
},
async list() {
return transcripts
},
} satisfies Partial<FileTranscriptStore>
const runtimeInputs: unknown[] = []
const runtime: AgentRuntime = {
async status() {
return { state: 'ready' }
@@ -166,39 +94,95 @@ describe('AgentHarnessService', () => {
async listSessions() {
return []
},
async getHistory() {
return { agentId: agent.id, sessionId: 'main', items: [] }
async getHistory(input) {
runtimeInputs.push(input)
return {
agentId: agent.id,
sessionId: 'main',
items: [
{
id: 'agent:agent-1:main:1',
agentId: agent.id,
sessionId: 'main',
role: 'assistant',
text: 'Done.',
createdAt: 1000,
reasoning: { text: 'checking state' },
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'read_file',
status: 'completed',
input: { path: 'src/index.ts' },
output: 'file contents',
},
],
},
],
}
},
async send() {
return new ReadableStream<AgentStreamEvent>({
start(controller) {
controller.enqueue({
type: 'text_delta',
text: 'partial answer',
stream: 'output',
})
},
})
return new ReadableStream<AgentStreamEvent>()
},
}
const service = new AgentHarnessService({
agentStore: agentStore as FileAgentStore,
transcriptStore: transcriptStore as FileTranscriptStore,
agentStore: createAgentStore([agent]) as FileAgentStore,
runtime,
})
const reader = (
await service.send({
agentId: agent.id,
message: 'hello',
})
).getReader()
await reader.read()
await reader.cancel()
const history = await service.getHistory(agent.id)
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'partial answer' },
])
expect(runtimeInputs).toEqual([{ agent, sessionId: 'main' }])
expect(history.items[0]).toMatchObject({
role: 'assistant',
reasoning: { text: 'checking state' },
toolCalls: [{ toolName: 'read_file' }],
})
})
})
function createAgentStore(agents: AgentDefinition[]) {
return {
async list() {
return agents
},
async get(id: string) {
return agents.find((agent) => agent.id === id) ?? null
},
async create(input) {
const agent: AgentDefinition = {
id: `agent-${agents.length + 1}`,
name: input.name,
adapter: input.adapter,
modelId: input.modelId,
reasoningEffort: input.reasoningEffort,
permissionMode: 'approve-all',
sessionKey: `agent:agent-${agents.length + 1}:main`,
createdAt: 1000,
updatedAt: 1000,
}
agents.push(agent)
return agent
},
async delete() {
return true
},
} satisfies Partial<FileAgentStore>
}
async function collectStream(
stream: ReadableStream<AgentStreamEvent>,
): Promise<AgentStreamEvent[]> {
const reader = stream.getReader()
const events: AgentStreamEvent[] = []
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
} finally {
reader.releaseLock()
}
return events
}

View File

@@ -11,8 +11,10 @@ import type {
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeOptions,
AcpSessionRecord,
AcpRuntime as AcpxCoreRuntime,
} from 'acpx/runtime'
import { createRuntimeStore } from 'acpx/runtime'
import { AcpxRuntime } from '../../../src/lib/agents/acpx-runtime'
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
@@ -112,6 +114,121 @@ describe('AcpxRuntime', () => {
])
})
it('maps persisted acpx session records into rich history entries', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
tempDirs.push(cwd, stateDir)
const timestamp = '2026-04-28T20:00:00.000Z'
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const record: AcpSessionRecord = {
schema: 'acpx.session.v1',
acpxRecordId: agent.sessionKey,
acpSessionId: 'sid-1',
agentSessionId: 'inner-1',
agentCommand: 'codex --acp',
cwd,
name: agent.sessionKey,
createdAt: timestamp,
lastUsedAt: timestamp,
lastSeq: 0,
eventLog: {
active_path: '',
segment_count: 0,
max_segment_bytes: 0,
max_segments: 0,
},
closed: false,
messages: [
{
User: {
id: 'user-1',
content: [{ Text: 'inspect history' }],
},
},
{
Agent: {
content: [
{ Thinking: { text: 'checking state', signature: null } },
{
ToolUse: {
id: 'tool-1',
name: 'read_file',
raw_input: '{"path":"src/index.ts"}',
input: { path: 'src/index.ts' },
is_input_complete: true,
thought_signature: null,
},
},
{ Text: 'Done.' },
],
tool_results: {
'tool-1': {
tool_use_id: 'tool-1',
tool_name: 'read_file',
is_error: false,
content: { Text: 'file contents' },
output: null,
},
},
},
},
],
updated_at: timestamp,
cumulative_token_usage: {},
request_token_usage: {},
acpx: {},
}
await createRuntimeStore({ stateDir }).save(record)
const history = await new AcpxRuntime({ cwd, stateDir }).getHistory({
agent,
sessionId: 'main',
})
expect(history).toEqual({
agentId: 'agent-1',
sessionId: 'main',
items: [
{
id: 'agent:agent-1:main:0',
agentId: 'agent-1',
sessionId: 'main',
role: 'user',
text: 'inspect history',
createdAt: Date.parse(timestamp),
},
{
id: 'agent:agent-1:main:1',
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'Done.',
createdAt: Date.parse(timestamp) + 1,
reasoning: { text: 'checking state' },
toolCalls: [
{
toolCallId: 'tool-1',
toolName: 'read_file',
status: 'completed',
input: { path: 'src/index.ts' },
output: 'file contents',
},
],
},
],
})
})
it('continues the turn when runtime config control is unavailable', async () => {
const calls: Array<{ method: string; input: unknown }> = []
const runtime = new AcpxRuntime({

View File

@@ -1,90 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FileTranscriptStore } from '../../../src/lib/agents/file-transcript-store'
describe('FileTranscriptStore', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('appends and lists main-session transcript entries', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-transcripts-'))
tempDirs.push(dir)
const store = new FileTranscriptStore({ rootDir: dir })
await store.append({
agentId: 'agent-1',
sessionId: 'main',
role: 'user',
text: 'hello',
})
await store.append({
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'hi',
})
expect(
(await store.list({ agentId: 'agent-1', sessionId: 'main' })).map(
({ role, text }) => ({ role, text }),
),
).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'hi' },
])
})
it('skips malformed JSONL lines when listing transcript entries', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-transcripts-'))
tempDirs.push(dir)
const store = new FileTranscriptStore({ rootDir: dir })
const agentDir = join(dir, 'agent-1')
await mkdir(agentDir, { recursive: true })
await writeFile(
join(agentDir, 'main.jsonl'),
[
JSON.stringify({
id: '1',
agentId: 'agent-1',
sessionId: 'main',
role: 'user',
text: 'hello',
createdAt: 1,
}),
'{bad json',
JSON.stringify({
id: '2',
agentId: 'agent-1',
sessionId: 'main',
role: 'assistant',
text: 'hi',
createdAt: 2,
}),
'',
].join('\n'),
'utf8',
)
expect(
(await store.list({ agentId: 'agent-1', sessionId: 'main' })).map(
({ role, text }) => ({ role, text }),
),
).toEqual([
{ role: 'user', text: 'hello' },
{ role: 'assistant', text: 'hi' },
])
})
})