mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat(pixel-office): show real subagent session events with dedupe
This commit is contained in:
@@ -9,11 +9,23 @@ const SESSION_LOOKBACK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const MAX_PARENT_SESSIONS_TO_PARSE = 40
|
||||
const ORPHAN_FALLBACK_WINDOW_MS = 15 * 60 * 1000
|
||||
const SUBAGENT_MAX_ACTIVE_MS = 30 * 60 * 1000
|
||||
const SUBAGENT_ACTIVITY_EVENT_LIMIT = 6
|
||||
const SUBAGENT_ACTIVITY_TEXT_MAX_LEN = 80
|
||||
|
||||
type SessionsIndex = Record<string, { sessionId?: string; updatedAt?: number }>
|
||||
|
||||
export interface SubagentActivityEvent {
|
||||
key: string
|
||||
text: string
|
||||
at: number
|
||||
}
|
||||
|
||||
export interface SubagentInfo {
|
||||
toolId: string
|
||||
label: string
|
||||
sessionKey?: string
|
||||
childSessionKey?: string
|
||||
activityEvents?: SubagentActivityEvent[]
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
@@ -81,13 +93,198 @@ function parseRecordTimestamp(record: unknown): number {
|
||||
return 0
|
||||
}
|
||||
|
||||
async function parseSubagentsFromSessionFile(filePath: string, sessionKey: string): Promise<SubagentInfo[]> {
|
||||
function normalizeActivityText(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null
|
||||
const compact = raw.replace(/\s+/g, ' ').trim()
|
||||
if (!compact) return null
|
||||
return compact.length > SUBAGENT_ACTIVITY_TEXT_MAX_LEN
|
||||
? `${compact.slice(0, SUBAGENT_ACTIVITY_TEXT_MAX_LEN - 1)}…`
|
||||
: compact
|
||||
}
|
||||
|
||||
function extractChildSessionKeyFromPayload(payload: unknown): string | null {
|
||||
if (!payload || typeof payload !== 'object') return null
|
||||
const data = payload as Record<string, unknown>
|
||||
const direct = data.childSessionKey
|
||||
if (typeof direct === 'string' && direct.includes(':subagent:')) return direct
|
||||
|
||||
const details = data.details
|
||||
if (details && typeof details === 'object') {
|
||||
const fromDetails = (details as Record<string, unknown>).childSessionKey
|
||||
if (typeof fromDetails === 'string' && fromDetails.includes(':subagent:')) return fromDetails
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractChildSessionKeyFromText(rawText: unknown): string | null {
|
||||
if (typeof rawText !== 'string' || !rawText.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(rawText)
|
||||
return extractChildSessionKeyFromPayload(parsed)
|
||||
} catch {
|
||||
const match = rawText.match(/agent:[^:\s]+:subagent:[a-f0-9-]+/i)
|
||||
return match ? match[0] : null
|
||||
}
|
||||
}
|
||||
|
||||
function extractChildSessionKeyFromToolResultMessage(message: unknown): string | null {
|
||||
if (!message || typeof message !== 'object') return null
|
||||
const msg = message as Record<string, unknown>
|
||||
const fromPayload = extractChildSessionKeyFromPayload(msg)
|
||||
if (fromPayload) return fromPayload
|
||||
|
||||
const content = msg.content
|
||||
if (!Array.isArray(content)) return null
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const text = (block as Record<string, unknown>).text
|
||||
const fromText = extractChildSessionKeyFromText(text)
|
||||
if (fromText) return fromText
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getSubagentSessionIdFromKey(sessionKey: string): string | null {
|
||||
const idx = sessionKey.indexOf(':subagent:')
|
||||
if (idx < 0) return null
|
||||
const sessionId = sessionKey.slice(idx + ':subagent:'.length).trim()
|
||||
return sessionId || null
|
||||
}
|
||||
|
||||
function resolveSubagentSessionId(
|
||||
childSessionKey: string,
|
||||
sessionsIndex?: SessionsIndex,
|
||||
): string | null {
|
||||
const fromIndex = sessionsIndex?.[childSessionKey]?.sessionId
|
||||
if (typeof fromIndex === 'string' && fromIndex.trim()) return fromIndex.trim()
|
||||
return getSubagentSessionIdFromKey(childSessionKey)
|
||||
}
|
||||
|
||||
async function parseSubagentActivityEvents(
|
||||
agentSessionsDir: string,
|
||||
childSessionKey: string,
|
||||
sessionsIndex?: SessionsIndex,
|
||||
): Promise<SubagentActivityEvent[]> {
|
||||
const sessionId = resolveSubagentSessionId(childSessionKey, sessionsIndex)
|
||||
if (!sessionId) return []
|
||||
const transcriptPath = path.join(agentSessionsDir, `${sessionId}.jsonl`)
|
||||
if (!existsSync(transcriptPath)) return []
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(transcriptPath, 'utf8')
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
const events: SubagentActivityEvent[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let record: any
|
||||
try {
|
||||
record = JSON.parse(lines[i])
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (record?.type !== 'message' || !record?.message) continue
|
||||
const at = parseRecordTimestamp(record)
|
||||
const msg = record.message
|
||||
const role = typeof msg.role === 'string' ? msg.role : ''
|
||||
const blocks = Array.isArray(msg.content) ? msg.content : []
|
||||
if (role === 'assistant') {
|
||||
for (let bi = 0; bi < blocks.length; bi++) {
|
||||
const block = blocks[bi]
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const b = block as Record<string, unknown>
|
||||
|
||||
if ((b.type === 'toolCall' || b.type === 'tool_use') && typeof b.name === 'string' && b.name) {
|
||||
events.push({
|
||||
key: `${record.id || i}:tool:${b.id || bi}`,
|
||||
text: `tool: ${b.name}`,
|
||||
at,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (b.type === 'text') {
|
||||
const normalized = normalizeActivityText(b.text)
|
||||
if (!normalized) continue
|
||||
events.push({
|
||||
key: `${record.id || i}:msg:${bi}`,
|
||||
text: normalized,
|
||||
at,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === 'toolResult') {
|
||||
const toolName = typeof msg.toolName === 'string' ? msg.toolName.trim() : ''
|
||||
const details = (msg.details && typeof msg.details === 'object')
|
||||
? (msg.details as Record<string, unknown>)
|
||||
: null
|
||||
const status = typeof details?.status === 'string' ? details.status : ''
|
||||
if (toolName) {
|
||||
const statusTail = status ? ` (${status})` : ''
|
||||
events.push({
|
||||
key: `${record.id || i}:result:${msg.toolCallId || ''}`,
|
||||
text: `result: ${toolName}${statusTail}`,
|
||||
at,
|
||||
})
|
||||
}
|
||||
for (let bi = 0; bi < blocks.length; bi++) {
|
||||
const block = blocks[bi]
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const normalized = normalizeActivityText((block as Record<string, unknown>).text)
|
||||
if (!normalized) continue
|
||||
events.push({
|
||||
key: `${record.id || i}:result-text:${bi}`,
|
||||
text: normalized,
|
||||
at,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
for (let bi = 0; bi < blocks.length; bi++) {
|
||||
const block = blocks[bi]
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const normalized = normalizeActivityText((block as Record<string, unknown>).text)
|
||||
if (!normalized) continue
|
||||
events.push({
|
||||
key: `${record.id || i}:user:${bi}`,
|
||||
text: `task: ${normalized}`,
|
||||
at,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => a.at - b.at)
|
||||
const deduped: SubagentActivityEvent[] = []
|
||||
const recentTextAt = new Map<string, number>()
|
||||
for (const event of events) {
|
||||
const lastAt = recentTextAt.get(event.text)
|
||||
// Skip near-duplicate text emitted within 1.5s from the same subagent timeline.
|
||||
if (typeof lastAt === 'number' && Math.abs(event.at - lastAt) <= 1500) continue
|
||||
recentTextAt.set(event.text, event.at)
|
||||
deduped.push(event)
|
||||
}
|
||||
return deduped.slice(-SUBAGENT_ACTIVITY_EVENT_LIMIT)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSubagentsFromSessionFile(
|
||||
agentSessionsDir: string,
|
||||
filePath: string,
|
||||
sessionKey: string,
|
||||
sessionsIndex?: SessionsIndex,
|
||||
): Promise<SubagentInfo[]> {
|
||||
const subagents: SubagentInfo[] = []
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf8')
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
|
||||
const activeSubtasks = new Map<string, { label: string; at: number }>()
|
||||
const activeSubtasks = new Map<string, { label: string; at: number; childSessionKey?: string }>()
|
||||
const spawnToolIds = new Set<string>()
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -113,7 +310,15 @@ async function parseSubagentsFromSessionFile(filePath: string, sessionKey: strin
|
||||
if (record.type === 'user' && record.message?.content) {
|
||||
const blocks = Array.isArray(record.message.content) ? record.message.content : []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool_result' && block.tool_use_id && !spawnToolIds.has(block.tool_use_id)) {
|
||||
if (block.type === 'tool_result' && block.tool_use_id) {
|
||||
if (spawnToolIds.has(block.tool_use_id)) {
|
||||
const childSessionKey = extractChildSessionKeyFromToolResultMessage(block)
|
||||
if (childSessionKey && activeSubtasks.has(block.tool_use_id)) {
|
||||
const prev = activeSubtasks.get(block.tool_use_id)!
|
||||
activeSubtasks.set(block.tool_use_id, { ...prev, childSessionKey })
|
||||
}
|
||||
continue
|
||||
}
|
||||
activeSubtasks.delete(block.tool_use_id)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +345,14 @@ async function parseSubagentsFromSessionFile(filePath: string, sessionKey: strin
|
||||
} else if (role === 'toolResult') {
|
||||
const toolCallId = typeof msg.toolCallId === 'string' ? msg.toolCallId : ''
|
||||
const toolName = typeof msg.toolName === 'string' ? msg.toolName : ''
|
||||
if (toolCallId && spawnToolIds.has(toolCallId)) {
|
||||
const childSessionKey = extractChildSessionKeyFromToolResultMessage(msg)
|
||||
if (childSessionKey && activeSubtasks.has(toolCallId)) {
|
||||
const prev = activeSubtasks.get(toolCallId)!
|
||||
activeSubtasks.set(toolCallId, { ...prev, childSessionKey })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (toolCallId && !isSpawnTool(toolName) && !spawnToolIds.has(toolCallId)) {
|
||||
activeSubtasks.delete(toolCallId)
|
||||
}
|
||||
@@ -167,7 +380,17 @@ async function parseSubagentsFromSessionFile(filePath: string, sessionKey: strin
|
||||
for (const [toolId, state] of activeSubtasks.entries()) {
|
||||
if (state.at > 0 && now - state.at > SUBAGENT_MAX_ACTIVE_MS) continue
|
||||
const label = state.label
|
||||
subagents.push({ toolId, label, sessionKey })
|
||||
let activityEvents: SubagentActivityEvent[] | undefined
|
||||
if (state.childSessionKey) {
|
||||
activityEvents = await parseSubagentActivityEvents(agentSessionsDir, state.childSessionKey, sessionsIndex)
|
||||
}
|
||||
subagents.push({
|
||||
toolId,
|
||||
label,
|
||||
sessionKey,
|
||||
childSessionKey: state.childSessionKey,
|
||||
activityEvents: activityEvents && activityEvents.length > 0 ? activityEvents : undefined,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
@@ -183,11 +406,12 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis
|
||||
const sessionFiles: Array<{ sessionKey: string; filePath: string; updatedAt: number }> = []
|
||||
const knownFilePaths = new Set<string>()
|
||||
const subagentSessionIds = new Set<string>()
|
||||
let sessionsIndex: SessionsIndex = {}
|
||||
const sessionsIndexPath = path.join(agentSessionsDir, 'sessions.json')
|
||||
if (existsSync(sessionsIndexPath)) {
|
||||
try {
|
||||
const sessionsIndexRaw = await fs.readFile(sessionsIndexPath, 'utf8')
|
||||
const sessionsIndex = JSON.parse(sessionsIndexRaw) as Record<string, { sessionId?: string; updatedAt?: number }>
|
||||
sessionsIndex = JSON.parse(sessionsIndexRaw) as SessionsIndex
|
||||
for (const [sessionKey, meta] of Object.entries(sessionsIndex)) {
|
||||
if (!meta || typeof meta.sessionId !== 'string' || !meta.sessionId) continue
|
||||
if (sessionKey.includes(':subagent:')) {
|
||||
@@ -242,7 +466,7 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis
|
||||
|
||||
sessionFiles.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
const candidates = sessionFiles.slice(0, MAX_PARENT_SESSIONS_TO_PARSE)
|
||||
const nested = await Promise.all(candidates.map((s) => parseSubagentsFromSessionFile(s.filePath, s.sessionKey)))
|
||||
const nested = await Promise.all(candidates.map((s) => parseSubagentsFromSessionFile(agentSessionsDir, s.filePath, s.sessionKey, sessionsIndex)))
|
||||
const dedupe = new Set<string>()
|
||||
for (const list of nested) {
|
||||
for (const sub of list) {
|
||||
|
||||
@@ -215,6 +215,7 @@ export default function PixelOfficePage() {
|
||||
const savedLayoutRef = useRef<OfficeLayout | null>(cachedSavedLayout)
|
||||
const animationFrameIdRef = useRef<number | null>(null)
|
||||
const prevAgentStatesRef = useRef<Map<string, string>>(new Map(cachedPrevAgentStates))
|
||||
const seenSubagentEventKeysRef = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const [agents, setAgents] = useState<AgentActivity[]>(cachedAgents)
|
||||
const [hoveredAgentId, setHoveredAgentId] = useState<number | null>(null)
|
||||
@@ -604,7 +605,8 @@ export default function PixelOfficePage() {
|
||||
ch.systemRoleType === 'gateway_sre' &&
|
||||
ch.systemStatus === 'down' &&
|
||||
ch.state === 'idle'
|
||||
if (!workingCharIds.has(ch.id) && !isSreBlackword) continue
|
||||
const hasInjectedSnippet = ch.codeSnippets.length > 0
|
||||
if (!workingCharIds.has(ch.id) && !isSreBlackword && !hasInjectedSnippet) continue
|
||||
if (ch.codeSnippets.length === 0) continue
|
||||
const anchorX = ox + ch.x * zoom
|
||||
const anchorY = containerTop + oy + (ch.y - (isSreBlackword ? 24 : 10)) * zoom
|
||||
@@ -712,10 +714,47 @@ export default function PixelOfficePage() {
|
||||
setAgents(newAgents)
|
||||
cachedAgents = newAgents
|
||||
|
||||
if (officeRef.current) {
|
||||
syncAgentsToOffice(newAgents, officeRef.current, agentIdMapRef.current, nextIdRef.current)
|
||||
const office = officeRef.current
|
||||
if (office) {
|
||||
syncAgentsToOffice(newAgents, office, agentIdMapRef.current, nextIdRef.current)
|
||||
cachedAgentIdMap = new Map(agentIdMapRef.current)
|
||||
cachedNextCharacterId = nextIdRef.current.current
|
||||
|
||||
const seen = seenSubagentEventKeysRef.current
|
||||
const now = Date.now()
|
||||
for (const [key, ts] of seen.entries()) {
|
||||
if (now - ts > 24 * 60 * 60 * 1000) seen.delete(key)
|
||||
}
|
||||
for (const agent of newAgents) {
|
||||
const parentCharId = agentIdMapRef.current.get(agent.agentId)
|
||||
if (typeof parentCharId !== 'number') continue
|
||||
if (!agent.subagents?.length) continue
|
||||
for (const sub of agent.subagents) {
|
||||
if (!sub.activityEvents?.length) continue
|
||||
const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId
|
||||
const subCharId = office.getSubagentId(parentCharId, subKey)
|
||||
if (subCharId == null) continue
|
||||
|
||||
const orderedEvents = (sub.activityEvents || []).slice().sort((a, b) => a.at - b.at)
|
||||
let emittedNew = false
|
||||
for (const event of orderedEvents) {
|
||||
const uniq = `${agent.agentId}:${subKey}:${event.key}`
|
||||
if (seen.has(uniq)) continue
|
||||
seen.set(uniq, now)
|
||||
office.pushCodeSnippet(subCharId, event.text)
|
||||
emittedNew = true
|
||||
}
|
||||
|
||||
// Fallback: if no parsed activity events yet, show subagent task label once.
|
||||
if (!emittedNew && orderedEvents.length === 0 && sub.label) {
|
||||
const fallbackUniq = `${agent.agentId}:${subKey}:label`
|
||||
if (!seen.has(fallbackUniq)) {
|
||||
seen.set(fallbackUniq, now)
|
||||
office.pushCodeSnippet(subCharId, `task: ${sub.label}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Play sound when agent transitions to waiting
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface SubagentInfo {
|
||||
toolId: string
|
||||
label: string
|
||||
sessionKey?: string
|
||||
childSessionKey?: string
|
||||
activityEvents?: Array<{ key: string; text: string; at: number }>
|
||||
}
|
||||
|
||||
export interface AgentActivity {
|
||||
|
||||
@@ -1222,6 +1222,23 @@ export class OfficeState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Push an explicit snippet bubble (e.g. subagent session updates). */
|
||||
pushCodeSnippet(id: number, text: string): void {
|
||||
const ch = this.characters.get(id)
|
||||
if (!ch || ch.isCat || ch.isLobster) return
|
||||
const compact = text.replace(/\s+/g, ' ').trim()
|
||||
if (!compact) return
|
||||
ch.codeSnippets.push({
|
||||
text: compact.length > 90 ? `${compact.slice(0, 89)}…` : compact,
|
||||
age: 0,
|
||||
x: (Math.random() - 0.5) * 20,
|
||||
y: 0,
|
||||
})
|
||||
if (ch.codeSnippets.length > 4) {
|
||||
ch.codeSnippets = ch.codeSnippets.slice(-4)
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss bubble on click — permission: instant, waiting: quick fade */
|
||||
dismissBubble(id: number): void {
|
||||
const ch = this.characters.get(id)
|
||||
@@ -1324,9 +1341,13 @@ export class OfficeState {
|
||||
// Code snippet particles:
|
||||
// - Working agents: regular coding snippets
|
||||
// - Gateway SRE in down state: ops slang ("运维黑话")
|
||||
if (isSreFirefighting && !ch.isCat && !ch.isLobster) {
|
||||
// - Subagent/session-driven snippets can be injected externally via pushCodeSnippet().
|
||||
if (!ch.isCat && !ch.isLobster) {
|
||||
for (const s of ch.codeSnippets) s.age += dt
|
||||
ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME)
|
||||
}
|
||||
|
||||
if (isSreFirefighting && !ch.isCat && !ch.isLobster) {
|
||||
if (ch.codeSnippets.length < 3 && Math.random() < dt * SRE_BLACKWORD_SPAWN_RATE) {
|
||||
ch.codeSnippets.push({
|
||||
text: SRE_BLACKWORDS[Math.floor(Math.random() * SRE_BLACKWORDS.length)],
|
||||
@@ -1335,10 +1356,7 @@ export class OfficeState {
|
||||
y: 0,
|
||||
})
|
||||
}
|
||||
} else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster) {
|
||||
// Age existing snippets and remove expired ones
|
||||
for (const s of ch.codeSnippets) s.age += dt
|
||||
ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME)
|
||||
} else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster && !ch.isSubagent) {
|
||||
// Spawn new snippet randomly
|
||||
if (ch.codeSnippets.length < 2 && Math.random() < dt * CODE_SNIPPET_SPAWN_RATE) {
|
||||
ch.codeSnippets.push({
|
||||
@@ -1348,8 +1366,6 @@ export class OfficeState {
|
||||
y: 0,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
ch.codeSnippets = []
|
||||
}
|
||||
}
|
||||
// Remove characters that finished despawn
|
||||
|
||||
Reference in New Issue
Block a user