feat(pixel-office): show real subagent session events with dedupe

This commit is contained in:
xmanrui
2026-03-06 01:52:12 +08:00
parent ea97a47eaf
commit bf6fc5d4d3
4 changed files with 297 additions and 16 deletions
+230 -6
View File
@@ -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) {
+42 -3
View File
@@ -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
+2
View File
@@ -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 {
+23 -7
View File
@@ -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