Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import {
buildChatHistoryFromTurns,
type ChatHistoryEntry,
chatWithAgent,
fetchAgentHistory,
type OpenClawStreamEvent,
} from './useOpenClaw'

Expand All @@ -43,6 +45,75 @@ interface ChatTurn {
userText: string
parts: AssistantPart[]
done: boolean
source?: string
}

function cleanUserText(text: string): string {
return text
.replace(/^\[cron:[^\]]*\]\s*/i, '')
.replace(/Current time:.*$/gm, '')
.trim()
}

function historyToTurns(entries: ChatHistoryEntry[]): ChatTurn[] {
const sorted = [...entries].sort((a, b) => {
const aTs = a.messages[0]?.timestamp ?? 0
const bTs = b.messages[0]?.timestamp ?? 0
return aTs - bTs
})
Comment on lines +59 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sort key falls back to 0 for all timestamp-less sessions

Sessions are sorted by messages[0]?.timestamp ?? 0, but timestamp is an optional field on ChatHistoryMessage, so every session without timestamps collapses to the same key and ordering becomes undefined. The session object already carries a reliable updatedAt field — prefer that as the sort key.

Suggested change
const sorted = [...entries].sort((a, b) => {
const aTs = a.messages[0]?.timestamp ?? 0
const bTs = b.messages[0]?.timestamp ?? 0
return aTs - bTs
})
const sorted = [...entries].sort((a, b) => {
const aTs = a.session.updatedAt ?? a.messages[0]?.timestamp ?? 0
const bTs = b.session.updatedAt ?? b.messages[0]?.timestamp ?? 0
return aTs - bTs
})
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx
Line: 59-63

Comment:
**Sort key falls back to 0 for all timestamp-less sessions**

Sessions are sorted by `messages[0]?.timestamp ?? 0`, but `timestamp` is an optional field on `ChatHistoryMessage`, so every session without timestamps collapses to the same key and ordering becomes undefined. The session object already carries a reliable `updatedAt` field — prefer that as the sort key.

```suggestion
  const sorted = [...entries].sort((a, b) => {
    const aTs = a.session.updatedAt ?? a.messages[0]?.timestamp ?? 0
    const bTs = b.session.updatedAt ?? b.messages[0]?.timestamp ?? 0
    return aTs - bTs
  })
```

How can I resolve this? If you propose a fix, please make it concise.


const turns: ChatTurn[] = []

for (const entry of sorted) {
// Limit to last 20 messages per session to avoid huge histories
const msgs = entry.messages.slice(-20)
let current: ChatTurn | null = null

for (const msg of msgs) {
if (msg.role === 'user') {
if (current) turns.push(current)
const rawText = msg.content
.filter((b) => b.type === 'text' && b.text)
.map((b) => b.text ?? '')
.join('\n')

const userText = cleanUserText(rawText)
if (!userText) {
current = null
continue
}

current = {
id: crypto.randomUUID(),
userText,
parts: [],
done: true,
source: entry.session.source,
}
} else if (msg.role === 'assistant') {
// If no current turn (e.g. user message was filtered), create
// one without user text — this handles proactive/cron responses
if (!current) {
current = {
id: crypto.randomUUID(),
userText: '',
parts: [],
done: true,
source: entry.session.source,
}
}
for (const block of msg.content) {
if (block.type === 'text' && block.text) {
current.parts.push({ kind: 'text', text: block.text })
}
}
}
}

if (current && current.parts.length > 0) turns.push(current)
}

return turns
}

interface AgentChatProps {
Expand All @@ -59,6 +130,7 @@ export const AgentChat: FC<AgentChatProps> = ({
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const streamAbortRef = useRef<AbortController | null>(null)
Expand All @@ -75,6 +147,23 @@ export const AgentChat: FC<AgentChatProps> = ({
scrollToBottom()
}, [turns])

useEffect(() => {
let active = true
fetchAgentHistory(agentId)
.then((entries) => {
if (!active) return
const historicTurns = historyToTurns(entries)
if (historicTurns.length > 0) setTurns(historicTurns)
setHistoryLoaded(true)
})
.catch(() => {
if (active) setHistoryLoaded(true)
})
return () => {
active = false
}
}, [agentId])

useEffect(() => {
return () => {
streamAbortRef.current?.abort()
Expand Down Expand Up @@ -263,6 +352,22 @@ export const AgentChat: FC<AgentChatProps> = ({
}
}

if (!historyLoaded) {
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="size-4" />
</Button>
<h2 className="font-semibold text-lg">{agentName}</h2>
</div>
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
</div>
)
}

return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
<div className="flex items-center gap-2 border-b px-4 py-3">
Expand All @@ -275,14 +380,27 @@ export const AgentChat: FC<AgentChatProps> = ({
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{turn.source &&
turn.source !== 'user-chat' &&
turn.source !== 'other' && (
<div className="mb-1 text-muted-foreground text-xs">
{turn.source === 'cron'
? 'Scheduled Task'
: turn.source === 'hook'
? 'Hook'
: turn.source}
</div>
)}
{/* User message (skip if empty — proactive/cron response) */}
{turn.userText && (
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
)}

{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,50 @@ export function buildChatHistoryFromTurns(
return messages
}

export interface ChatHistoryBlock {
type: 'text' | 'toolCall' | 'thinking'
text?: string
name?: string
arguments?: unknown
thinking?: string
}

export interface ChatHistoryMessage {
role: 'user' | 'assistant' | 'toolResult'
content: ChatHistoryBlock[]
timestamp?: number
usage?: { input: number; output: number }
stopReason?: string
toolName?: string
isError?: boolean
}

export interface ChatHistorySession {
key: string
updatedAt: number
sessionId: string
agentId: string
source: string
}

export interface ChatHistoryEntry {
session: ChatHistorySession
messages: ChatHistoryMessage[]
}

export async function fetchAgentHistory(
agentId: string,
limit = 10,
): Promise<ChatHistoryEntry[]> {
const baseUrl = await getAgentServerUrl()
const res = await fetch(
`${baseUrl}/claw/agents/${agentId}/history?limit=${limit}`,
)
if (!res.ok) return []
const data = (await res.json()) as { entries: ChatHistoryEntry[] }
return data.entries ?? []
}

export async function chatWithAgent(
agentId: string,
message: string,
Expand Down
110 changes: 110 additions & 0 deletions packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,73 @@ import {
OpenClawInvalidAgentNameError,
OpenClawProtectedAgentError,
} from '../services/openclaw/errors'
import type { OpenClawChatMessage } from '../services/openclaw/openclaw-cli-client'
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
import { getOpenClawService } from '../services/openclaw/openclaw-service'

/**
* Filter non-user-facing messages from chat.history.
*
* OpenClaw's session history contains three kinds of noise:
*
* 1. Context replays — When a new message arrives in an existing session,
* OpenClaw bundles the entire prior conversation into a single user
* message prefixed with "[Chat messages since your last reply]".
* This is internal context for the model, not user input.
*
* 2. System events — Cron/heartbeat triggers formatted as user messages
* starting with "System: [timestamp]" and containing
* "Handle this reminder internally".
*
* 3. Heartbeat responses — Assistant messages that are just "HEARTBEAT_OK",
* which is OpenClaw's standard heartbeat acknowledgment token.
*/
function filterSystemMessages(
messages: OpenClawChatMessage[],
): OpenClawChatMessage[] {
const result: OpenClawChatMessage[] = []

for (const msg of messages) {
const text = msg.content
.filter((b) => b.type === 'text')
.map((b) => b.text ?? '')
.join('')
.trim()

// Skip heartbeat responses
if (msg.role === 'assistant' && text.startsWith('HEARTBEAT')) continue

// Skip system event triggers (cron/heartbeat)
if (msg.role === 'user' && text.includes('Handle this reminder internally'))
continue

// Context-replay messages: extract the actual new user message
if (
msg.role === 'user' &&
text.startsWith('[Chat messages since your last reply')
) {
const marker = '[Current message - respond to this]'
const idx = text.indexOf(marker)
if (idx >= 0) {
let actual = text.slice(idx + marker.length).trim()
// Strip "User: " prefix
actual = actual.replace(/^User:\s*/i, '')
if (actual) {
result.push({
...msg,
content: [{ type: 'text', text: actual }],
})
}
}
continue
}

result.push(msg)
}

return result
}

function getCreateAgentValidationError(body: { name?: string }): string | null {
if (!body.name?.trim()) {
return 'Name is required'
Expand Down Expand Up @@ -344,6 +408,52 @@ export function createOpenClawRoutes() {
}
})

.get('/agents/:id/history', async (c) => {
const { id } = c.req.param()
const limit = Number(c.req.query('limit')) || 10
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 limit=0 silently becomes 10

Number('0') || 10 evaluates to 10 because 0 is falsy. A caller explicitly passing ?limit=0 would get 10 sessions back, not 0. Use Number(...) || 10 only when NaN is the expected bad input — otherwise check for NaN explicitly.

Suggested change
const limit = Number(c.req.query('limit')) || 10
const limitParam = Number(c.req.query('limit'))
const limit = Number.isNaN(limitParam) || limitParam <= 0 ? 10 : limitParam
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/server/src/api/routes/openclaw.ts
Line: 413

Comment:
**`limit=0` silently becomes 10**

`Number('0') || 10` evaluates to `10` because `0` is falsy. A caller explicitly passing `?limit=0` would get 10 sessions back, not 0. Use `Number(...) || 10` only when `NaN` is the expected bad input — otherwise check for `NaN` explicitly.

```suggestion
      const limitParam = Number(c.req.query('limit'))
      const limit = Number.isNaN(limitParam) || limitParam <= 0 ? 10 : limitParam
```

How can I resolve this? If you propose a fix, please make it concise.


try {
const allSessions = await getOpenClawService().listSessions(id)
const filtered = allSessions
.filter((s) => s.agentId === id || s.key.includes(`agent:${id}:`))
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, limit)

const classifySource = (key: string): string => {
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'
}

const entries = await Promise.all(
filtered.map(async (s) => {
const source = classifySource(s.key)
try {
const rawMessages = await getOpenClawService().getChatHistory(
s.key,
)

const messages = filterSystemMessages(rawMessages)

return { session: { ...s, source }, messages }
} catch {
return { session: { ...s, source }, messages: [] }
}
}),
)

// Filter out entries with no meaningful messages
const nonEmpty = entries.filter((e) => e.messages.length > 0)

return c.json({ entries: nonEmpty })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})

.get('/logs', async (c) => {
try {
const logs = await getOpenClawService().getLogs()
Expand Down
Loading
Loading