From b828639bb3050a4ad445723662eddf1a79687153 Mon Sep 17 00:00:00 2001 From: Aday Date: Sat, 5 Sep 2026 13:25:00 +0100 Subject: [PATCH 01/26] feat: add dynamic office mode for agent families --- README.md | 60 ++- docs/OFFICE_MODE.md | 98 +++++ extension/src/codex-rollout-parser.ts | 377 ++++++++++++++++- extension/src/codex-session-watcher.ts | 300 ++++++++++++-- extension/test/codex-rollout-parser.test.ts | 209 ++++++++++ extension/test/codex-session-watcher.test.ts | 179 ++++++++ package.json | 2 +- pnpm-workspace.yaml | 3 + scripts/run-workspace-tests.js | 33 ++ scripts/telemetry.test.ts | 78 ++-- scripts/telemetry.ts | 22 +- web/components/agent-visualizer/index.tsx | 218 ++++++---- .../agent-visualizer/office/OfficeView.tsx | 293 +++++++++++++ .../agent-visualizer/office/index.ts | 1 + .../agent-visualizer/office/office.module.css | 152 +++++++ .../simulation/handle-agent-events.test.ts | 76 ++++ web/hooks/simulation/handle-agent-events.ts | 93 +++-- .../simulation/handle-subagent-events.ts | 19 +- web/lib/office/index.ts | 12 + web/lib/office/project-office.test.ts | 121 ++++++ web/lib/office/project-office.ts | 384 ++++++++++++++++++ web/lib/office/types.ts | 81 ++++ web/package.json | 3 +- 23 files changed, 2591 insertions(+), 223 deletions(-) create mode 100644 docs/OFFICE_MODE.md create mode 100644 extension/test/codex-session-watcher.test.ts create mode 100644 scripts/run-workspace-tests.js create mode 100644 web/components/agent-visualizer/office/OfficeView.tsx create mode 100644 web/components/agent-visualizer/office/index.ts create mode 100644 web/components/agent-visualizer/office/office.module.css create mode 100644 web/hooks/simulation/handle-agent-events.test.ts create mode 100644 web/lib/office/index.ts create mode 100644 web/lib/office/project-office.test.ts create mode 100644 web/lib/office/project-office.ts create mode 100644 web/lib/office/types.ts diff --git a/README.md b/README.md index af79238c..3fcfa55b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,29 @@ Claude Code is powerful, but its execution is a black box — you see the final - **Interactive canvas**: Pan, zoom, click agents and tool calls to inspect details - **Timeline & transcript panels**: Review the full execution timeline, file attention heatmap, and message transcript - **JSONL log file support**: Point at any JSONL event log to replay or watch agent activity +- **Office mode (MVP)**: The visualizer opens in a privacy-scoped office view by default; the existing Graph view remains available as a reversible toggle + +## Office mode (MVP) + +Office is a read-only projection of the event stream already consumed by Agent +Flow. It places observed agents into evidence-backed work zones and shows +parent/child relationships only when a corresponding spawn or dispatch event +exists. It does not create a second source of truth or persist layout state. + +The projection discovers new Claude Code and Codex agents as events arrive. +Agent identities are deterministic and session-scoped, so equal names in +different sessions remain distinct and nested agents can be represented. Terra +and Luna receive their named avatar families; other and future model IDs are +kept as reported without a hard-coded model catalogue. + +Office intentionally exposes only bounded names, model IDs, states, zones, and +relationship evidence. Prompts, transcript text, file paths, and tool +arguments are not passed to the Office view. The implementation is local and +read-only: it does not write to `.codex`. + +For the local/demo workflow, use the commands below and open the displayed +localhost URL. Office is the initial view; select **Graph** to return to the +original canvas, and **Office** to switch back. ## Getting Started @@ -53,6 +76,9 @@ pnpm run dev # start the web app + event relay Open http://localhost:3000 and start a Claude Code session in another terminal — events will stream to the browser in real-time. +The relay binds to localhost for the local workflow. It reads the existing +Claude Code and Codex event sources; it does not require a hosted service. + ### VS Code Extension 1. Install the extension @@ -143,14 +169,23 @@ Created by [Simon Patole](https://github.com/patoles), for [CraftMyGame](https:/ ## Privacy & Telemetry -Agent Flow ships **opt-out** anonymous usage telemetry, enabled by default only -in the published `npx agent-flow-app` binary. `pnpm run dev` and the VS Code -extension emit nothing. Only aggregate events are sent — session count, -duration, event count, OS/arch, Agent Flow version, distinct model IDs -observed, which runtimes were watched, and error class names. Prompts, file -paths, tool calls, user info, and environment variables are never sent. +Telemetry is disabled by default for the Office MVP/local demo and creates no +install ID or telemetry directory. To opt in explicitly, set: -- **Turn off:** `export AGENT_FLOW_TELEMETRY=false` or `export DO_NOT_TRACK=1` +```bash +AGENT_FLOW_TELEMETRY=true pnpm run dev +``` + +In PowerShell, use `$env:AGENT_FLOW_TELEMETRY="true"` before starting the +command. Only the explicit value `true` (case-insensitive) opts in; +`DO_NOT_TRACK=1` always disables telemetry, even when opt-in is set. +The same variables can be exported before using another local entry point. +When telemetry is enabled by a published entry point, only aggregate events +are sent; prompts, file paths, tool calls, user info, and environment variables +are not sent. Office itself has no telemetry path and does not write `.codex`. + +- **Turn off:** unset `AGENT_FLOW_TELEMETRY` (the default) or use + `export DO_NOT_TRACK=1` (disabled installs write zero state to disk — no `~/.agent-flow/` directory) - **Inspect the payload:** `cat ~/.agent-flow/telemetry/events.jsonl` - **Full schema + exact fields:** see the v0.8.1 entry in @@ -159,6 +194,17 @@ paths, tool calls, user info, and environment variables are never sent. - **Reset your anonymous identity:** delete `~/.agent-flow/installation-id` — a fresh random UUIDv4 will be generated on next run +## MVP limitations and rollback + +Office states are derived only from events observed by the relay/parser. An +unseen event, an interrupted session, or an unfamiliar event type can leave an +agent as `unknown`, `stale`, or otherwise incomplete; the view does not infer +intent or claim a complete execution history. + +Rollback is reversible: close the local Office/Agent Flow process and reopen +the visualizer, then select **Graph**. No deployment or canary is implied by +this MVP documentation. + ## License diff --git a/docs/OFFICE_MODE.md b/docs/OFFICE_MODE.md new file mode 100644 index 00000000..50d9fcc0 --- /dev/null +++ b/docs/OFFICE_MODE.md @@ -0,0 +1,98 @@ +# Office mode + +Office mode is the default, read-only presentation introduced by the MVP. It +is a privacy-scoped projection over Agent Flow's existing Claude Code and Codex +events and materialized graph; it is not a replacement event store. + +## Local architecture + +The local relay/parser watches the existing Claude Code project transcripts and +Codex rollout JSONL files (honouring `CODEX_HOME` when set), then streams +observed events to the web UI over a localhost SSE connection. The Office +projection consumes only the small event/graph contract it needs: + +- bounded agent name, model ID, state/zone, session ID, and relationship evidence; +- no prompt or transcript payload, file path, or tool argument in the Office + projection; +- no Office persistence and no writes to `.codex`. + +The projection is pure and replayable. It can consume events or adapt the +already materialized Graph state. Unknown event types are ignored instead of +being converted into invented agent states. A stale decoration is applied +when the event clock has no recent observation (120 seconds by default). + +## Discovery, identity, and nesting + +New Claude Code and Codex sessions/agents are discovered automatically as the +local watchers observe them. Office IDs are deterministic opaque IDs derived +from the source agent ID and session ID. This keeps an identity stable across +render/replay while preventing same-named agents in separate sessions from +colliding. + +Nested agents are connected only by explicit `agent_spawn` or +`subagent_dispatch` evidence (or an existing Graph parent-child edge). Matching +names alone never create a parent/child relationship, and a parent that has +not yet been observed is not invented. + +## Models and states + +Model IDs are displayed as reported; there is no catalogue that blocks Terra, +Luna, or future providers/models. IDs containing Terra or Luna select the +corresponding avatar family. Other IDs use a deterministic generated avatar +key while preserving the original model ID. + +Zones mirror evidence-backed states such as planning, researching, editing, +executing, waiting for approval, blocked, completed, idle, unknown, and stale. +These are observations, not a claim about hidden reasoning or the full state +of a runtime. + +## Privacy and localhost + +Office is local and read-only. It deliberately omits prompts, paths, and tool +arguments from its view contract, and it does not write `.codex`. Telemetry is +disabled by default for the MVP/demo, so no environment setting is required: + +```bash +pnpm run dev +``` + +To opt in, set `AGENT_FLOW_TELEMETRY=true`; `DO_NOT_TRACK=1` always prevails. +In PowerShell, use `$env:AGENT_FLOW_TELEMETRY="true"` before running the +command. + +The relay and standalone UI use localhost (the relay's SSE endpoint is bound +to `127.0.0.1`). This is a local workflow, not a hosted deployment. + +## Start, demo, test, and build + +From the repository root: + +```bash +pnpm run dev +``` + +Open the reported `http://localhost:3000` URL. For a no-runtime visual check, +use `pnpm run dev:demo`. Useful verification commands are: + +```bash +pnpm run test +pnpm --filter agent-flow-web run build +pnpm run build:all +``` + +The focused Office projection tests live in +`web/lib/office/project-office.test.ts`; the commands above are the repository +test/build entry points and do not constitute a production deployment or +canary. + +## Rollback and honest limitations + +To roll back the presentation, close the local Agent Flow/Office process and +reopen the visualizer, then select **Graph**. Switching back to **Office** is +equally reversible; no data migration is required. + +States and relationships are based only on events actually observed by the +watcher/parser. Missing, delayed, interrupted, or unfamiliar events can leave +an agent unknown, stale, or incomplete. Office must not be read as proof of +intent, hidden work, or a complete execution history. No 30-minute canary or +deployment is claimed here because neither was executed as part of this MVP. diff --git a/extension/src/codex-rollout-parser.ts b/extension/src/codex-rollout-parser.ts index 60c7ab74..2eb69384 100644 --- a/extension/src/codex-rollout-parser.ts +++ b/extension/src/codex-rollout-parser.ts @@ -26,14 +26,18 @@ * only. event_msg.exec_command_end / patch_apply_end are * parallel signals and are skipped. * - * Subagents: Codex does not currently expose subagent spawning in rollouts. - * The parser emits a single orchestrator; if Codex adds spawn_agent / wait_agent - * in future, add mapping here. + * Collaboration calls are represented as ordinary Responses `function_call` + * items. The important detail is that the arguments are not a tool payload: + * `spawn_agent` takes `{ task_name, message, ... }`, while its successful + * result is either `{ task_name }` (older rollouts) or `{ agent_id, nickname }` + * (newer rollouts). Messages and prompts are deliberately never copied into + * agent events; only identity metadata is surfaced. */ import { AgentEvent } from './protocol' import { ORCHESTRATOR_NAME, HASH_PREFIX_MAX, MESSAGE_MAX, PREVIEW_MAX, RESULT_MAX, + SESSION_ID_DISPLAY, SYSTEM_PROMPT_BASE_TOKENS, SYSTEM_CONTENT_PREFIXES, } from './constants' import { @@ -67,6 +71,10 @@ export interface CodexRolloutState { model: string | null /** Cwd from session_meta, updated by turn_context. */ cwd: string | null + /** Session/thread id from session_meta, when present. */ + sessionId: string | null + /** Parent thread id from session_meta, when this is a nested rollout. */ + parentThreadId: string | null /** Label for the session (set from first non-system user message). */ label: string | null /** Pending tool calls, keyed by call_id. */ @@ -83,12 +91,22 @@ export interface CodexRolloutState { lastReportedTokens: number | null /** Authoritative model_context_window from event_msg.token_count, if any. */ reportedContextWindow: number | null + /** Collaboration calls waiting for their function_call_output. */ + pendingCollaborationCalls: Map + /** Reliable child identities observed in successful spawn results. */ + subagentIdentities: Map + /** Stable ids for which spawn events have already been emitted. */ + emittedSubagentIds: Set + /** Dispatch/return de-duplication keys. */ + emittedCollaborationEvents: Set } export function createCodexRolloutState(): CodexRolloutState { return { model: null, cwd: null, + sessionId: null, + parentThreadId: null, label: null, pendingToolCalls: new Map(), seenMessageHashes: new Set(), @@ -103,6 +121,10 @@ export function createCodexRolloutState(): CodexRolloutState { lastEmittedModel: null, lastReportedTokens: null, reportedContextWindow: null, + pendingCollaborationCalls: new Map(), + subagentIdentities: new Map(), + emittedSubagentIds: new Set(), + emittedCollaborationEvents: new Set(), } } @@ -115,6 +137,23 @@ export interface CodexParserDelegate { elapsed(): number /** Called when a session label is derived from the first user message. */ setLabel?(label: string): void + /** + * The stable visual identity assigned by the watcher after it has resolved + * the rollout parent graph. It is deliberately metadata-only: rollout + * prompts and task text are never used as agent names. + */ + principal?: CodexRolloutPrincipal +} + +/** The visual identity for one rollout inside its Codex thread family. */ +export interface CodexRolloutPrincipal { + id: string + name: string + isMain: boolean + parentId?: string + /** A missing-but-observed root thread. The watcher de-duplicates this event + * across every child rollout in the same family. */ + rootPlaceholder?: { id: string; name: string } } // ─── Record shapes (structural typing, all fields optional) ──────────────── @@ -142,7 +181,7 @@ interface FunctionCallPayload { interface FunctionCallOutputPayload { call_id?: string - output?: string | { output?: string; metadata?: unknown } + output?: unknown } interface CustomToolCallPayload { @@ -153,7 +192,7 @@ interface CustomToolCallPayload { interface CustomToolCallOutputPayload { call_id?: string - output?: string | { output?: string; metadata?: unknown } + output?: unknown } interface WebSearchCallPayload { @@ -163,11 +202,31 @@ interface WebSearchCallPayload { interface SessionMetaPayload { id?: string + session_id?: string + parent_thread_id?: string cwd?: string cli_version?: string base_instructions?: { text?: string } } +type CollaborationToolName = 'spawn_agent' | 'send_message' | 'followup_task' | 'wait_agent' + +interface PendingCollaborationCall { + name: CollaborationToolName + taskName?: string + target?: string + targets?: string[] + model?: string +} + +interface CodexSubagentIdentity { + id: string + name: string + task?: string + model?: string + parentId: string +} + interface TurnContextPayload { model?: string cwd?: string @@ -236,6 +295,46 @@ function parseArgsJson(raw: string | undefined): Record | undef } catch { return undefined } } +function isCollaborationTool(name: string): name is CollaborationToolName { + return name === 'spawn_agent' || name === 'send_message' || name === 'followup_task' || name === 'wait_agent' +} + +/** Parse a tool result without exposing its text to the event stream. */ +function parseStructuredOutput(raw: unknown): unknown { + let value = raw + for (let i = 0; i < 2; i++) { + if (isRecord(value) && 'output' in value) { + value = value.output + continue + } + if (typeof value === 'string') { + try { + value = JSON.parse(value) + continue + } catch { /* plain result text */ } + } + break + } + return value +} + +function safeIdentityPart(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const value = raw.trim() + // IDs/nicknames are metadata. Do not turn arbitrary prompt text into an id. + if (!value || value.length > 256 || /[\r\n]/.test(value)) return undefined + return value +} + +function collaborationReference(raw: unknown): string | undefined { + const direct = safeIdentityPart(raw) + if (direct) return direct + if (!isRecord(raw)) return undefined + return safeIdentityPart( + raw.agent_id || raw.id || raw.thread_id || raw.nickname || raw.name || raw.task_name, + ) +} + /** Extract an output string from a function_call_output payload. * Codex sometimes encodes output as a JSON object with nested .output. */ function extractOutputString(raw: FunctionCallOutputPayload['output']): string { @@ -271,18 +370,23 @@ export class CodexRolloutParser { try { record = JSON.parse(trimmed) as RolloutRecord } catch { return /* partial line at file tail; resume on next read */ } - this.ensureSpawned(state) - switch (record.type) { case 'session_meta': - return this.handleSessionMeta(record.payload as SessionMetaPayload, state) + this.handleSessionMeta(record.payload as SessionMetaPayload, state, false) + this.ensureSpawned(state) + this.emitContextUpdate(state) + return case 'turn_context': + this.ensureSpawned(state) return this.handleTurnContext(record.payload as TurnContextPayload, state) case 'response_item': + this.ensureSpawned(state) return this.handleResponseItem(record.payload, state) case 'event_msg': + this.ensureSpawned(state) return this.handleEventMsg(record.payload, state) case 'compacted': + this.ensureSpawned(state) return this.handleCompacted(record.payload as CompactedPayload, state) // Ignore unknown types (forward-compatible). } @@ -293,17 +397,66 @@ export class CodexRolloutParser { private ensureSpawned(state: CodexRolloutState): void { if (state.spawnEmitted) return state.spawnEmitted = true + const principal = this.principalFor(state) + if (principal.rootPlaceholder) { + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'agent_spawn', + payload: { + id: principal.rootPlaceholder.id, + name: principal.rootPlaceholder.name, + isMain: true, + task: 'Observed parent session', + runtime: 'codex', + placeholder: true, + }, + }) + } this.delegate.emit({ time: this.delegate.elapsed(), type: 'agent_spawn', - payload: { name: ORCHESTRATOR_NAME, isMain: true, task: 'Codex session', runtime: 'codex' }, + payload: { + id: principal.id, + name: principal.name, + ...(principal.parentId ? { parentId: principal.parentId } : {}), + isMain: principal.isMain, + ...(principal.isMain ? { task: 'Codex session' } : {}), + runtime: 'codex', + }, }) } + /** Use watcher-resolved graph metadata when available. The fallback keeps + * standalone parser use safe: a child rollout creates an observed-parent + * placeholder and is never promoted to the main agent. */ + private principalFor(state: CodexRolloutState): CodexRolloutPrincipal { + if (this.delegate.principal) return this.delegate.principal + const id = state.sessionId || ORCHESTRATOR_NAME + const parentId = state.parentThreadId || undefined + if (!parentId) { + return { id, name: ORCHESTRATOR_NAME, isMain: true } + } + return { + id, + name: `Codex agent ${id.slice(0, SESSION_ID_DISPLAY)}`, + isMain: false, + parentId, + rootPlaceholder: { id: parentId, name: ORCHESTRATOR_NAME }, + } + } + // ─── session_meta ──────────────────────────────────────────────────────── - private handleSessionMeta(payload: SessionMetaPayload | undefined, state: CodexRolloutState): void { + private handleSessionMeta( + payload: SessionMetaPayload | undefined, + state: CodexRolloutState, + emitUpdate = true, + ): void { if (!payload) return + const sessionId = safeIdentityPart(payload.id || payload.session_id) + if (sessionId) state.sessionId = sessionId + const parentThreadId = safeIdentityPart(payload.parent_thread_id) + if (parentThreadId) state.parentThreadId = parentThreadId if (typeof payload.cwd === 'string') state.cwd = payload.cwd // Estimate system-prompt tokens from base_instructions if present const sys = payload.base_instructions?.text @@ -313,7 +466,7 @@ export class CodexRolloutParser { SYSTEM_PROMPT_BASE_TOKENS, ) } - this.emitContextUpdate(state) + if (emitUpdate) this.emitContextUpdate(state) } // ─── turn_context ──────────────────────────────────────────────────────── @@ -327,7 +480,7 @@ export class CodexRolloutParser { this.delegate.emit({ time: this.delegate.elapsed(), type: 'model_detected', - payload: { agent: ORCHESTRATOR_NAME, model: payload.model }, + payload: { agent: this.currentAgentId(state), model: payload.model }, }) } } @@ -349,7 +502,7 @@ export class CodexRolloutParser { case 'custom_tool_call_output': return this.handleCustomToolCallOutput(payload as CustomToolCallOutputPayload, state) case 'web_search_call': - return this.handleWebSearchCall(payload as WebSearchCallPayload) + return this.handleWebSearchCall(payload as WebSearchCallPayload, state) // `reasoning` items carry encrypted_content + a short summary; we emit // plaintext reasoning from event_msg.agent_reasoning instead. } @@ -383,7 +536,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'message', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), role, content: text.slice(0, MESSAGE_MAX), }, @@ -397,6 +550,10 @@ export class CodexRolloutParser { if (!callId) return const args = parseArgsJson(payload.arguments) + if (isCollaborationTool(name)) { + this.handleCollaborationCall(name, args, callId, state) + return + } const argsSummary = summarizeInput(name, args) const filePath = extractFilePath(args) @@ -408,7 +565,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_start', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: name, args: argsSummary, preview: `${name}: ${argsSummary}`.slice(0, PREVIEW_MAX), @@ -420,6 +577,12 @@ export class CodexRolloutParser { private handleFunctionCallOutput(payload: FunctionCallOutputPayload, state: CodexRolloutState): void { const callId = payload.call_id if (!callId) return + const collaboration = state.pendingCollaborationCalls.get(callId) + if (collaboration) { + state.pendingCollaborationCalls.delete(callId) + this.handleCollaborationOutput(collaboration, payload.output, state) + return + } const pending = state.pendingToolCalls.get(callId) if (!pending) return state.pendingToolCalls.delete(callId) @@ -436,7 +599,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_end', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: pending.name, result: resultSummary, tokenCost, @@ -465,7 +628,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_start', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: name, args: argsSummary, preview: `${name}: ${argsSummary}`.slice(0, PREVIEW_MAX), @@ -479,7 +642,177 @@ export class CodexRolloutParser { this.handleFunctionCallOutput(payload, state) } - private handleWebSearchCall(payload: WebSearchCallPayload): void { + private currentAgentId(state: CodexRolloutState): string { + return this.delegate.principal?.id || state.sessionId || ORCHESTRATOR_NAME + } + + /** + * Track a collaboration call and emit only a safe, metadata-only tool card. + * Real Codex rollouts contain the complete child prompt in `message`; it is + * intentionally not passed to summarizeInput, extractInputData, or events. + */ + private handleCollaborationCall( + name: CollaborationToolName, + args: Record | undefined, + callId: string, + state: CodexRolloutState, + ): void { + const taskName = safeIdentityPart(args?.task_name) + const target = collaborationReference(args?.target) + const targets = Array.isArray(args?.targets) + ? args!.targets.map(collaborationReference).filter((x): x is string => !!x) + : undefined + const model = safeIdentityPart(args?.model) + state.pendingCollaborationCalls.set(callId, { + name, taskName, target, targets, model, + }) + + const safeArgs = taskName || target || (targets && targets.length > 0 ? `${targets.length} targets` : '') || name + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'tool_call_start', + payload: { + agent: this.currentAgentId(state), + tool: name, + args: safeArgs.slice(0, PREVIEW_MAX), + preview: `${name}: ${safeArgs}`.slice(0, PREVIEW_MAX), + }, + }) + } + + private handleCollaborationOutput( + call: PendingCollaborationCall, + rawOutput: unknown, + state: CodexRolloutState, + ): void { + const structured = parseStructuredOutput(rawOutput) + const output = typeof structured === 'string' ? structured : '' + const outputRecord = isRecord(structured) ? structured : undefined + + // Keep collaboration tool cards balanced without reflecting result text. + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'tool_call_end', + payload: { + agent: this.currentAgentId(state), + tool: call.name, + result: 'completed', + tokenCost: 0, + }, + }) + + if (call.name === 'spawn_agent') { + const child = this.identityFromSpawn(call, outputRecord, state) + if (child) this.emitSubagent(child, state) + return + } + + const references = call.targets ?? (call.target ? [call.target] : []) + if (references.length === 0) return // wait_agent(timeout_ms) has no child identity + for (const reference of references) { + const child = this.resolveSubagent(reference, state) + if (!child) continue + if (call.name === 'wait_agent') { + this.emitSubagentReturn(child, state) + } else { + this.emitSubagentDispatch(child, state, call.name) + } + } + } + + private identityFromSpawn( + call: PendingCollaborationCall, + output: Record | undefined, + state: CodexRolloutState, + ): CodexSubagentIdentity | undefined { + // New schema: successful output has an opaque stable id and nickname. + const id = safeIdentityPart(output?.agent_id || output?.id || output?.thread_id) + // Do not promote task_name (or any task text) to an agent name. A missing + // nickname still has a stable, opaque-id-derived display name. + if (!id) return undefined + const name = safeIdentityPart(output?.nickname || output?.name) + || `Codex agent ${id.slice(0, SESSION_ID_DISPLAY)}` + const stableId = id + const parentId = safeIdentityPart(output?.parent_id || output?.parentId || output?.parent) || this.currentAgentId(state) + return { + id: stableId, + name, + parentId, + ...((safeIdentityPart(output?.model) || call.model) + ? { model: safeIdentityPart(output?.model) || call.model } + : {}), + } + } + + private resolveSubagent(reference: string, state: CodexRolloutState): CodexSubagentIdentity | undefined { + const byId = state.subagentIdentities.get(reference) + if (byId) return byId + const byName = Array.from(state.subagentIdentities.values()).filter(child => child.name === reference) + // A nominal target is safe only when it resolves to exactly one stable id. + // Unknown and homonymous targets must not create a fabricated relation. + return byName.length === 1 ? byName[0] : undefined + } + + private emitSubagent(child: CodexSubagentIdentity, state: CodexRolloutState): void { + if (state.emittedSubagentIds.has(child.id)) return + state.emittedSubagentIds.add(child.id) + state.subagentIdentities.set(child.id, child) + this.emitSubagentDispatch(child, state, 'spawn_agent') + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'agent_spawn', + payload: { + id: child.id, + name: child.name, + parentId: child.parentId, + parent: this.parentName(child.parentId, state), + isMain: false, + task: child.task || child.name, + ...(child.model ? { model: child.model } : {}), + runtime: 'codex', + }, + }) + } + + private emitSubagentDispatch(child: CodexSubagentIdentity, state: CodexRolloutState, operation: string): void { + const key = `dispatch:${operation}:${child.id}` + if (state.emittedCollaborationEvents.has(key)) return + state.emittedCollaborationEvents.add(key) + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'subagent_dispatch', + payload: { + parent: this.parentName(child.parentId, state), + parentId: child.parentId, + child: child.name, + childId: child.id, + task: operation === 'spawn_agent' ? (child.task || child.name) : operation, + }, + }) + } + + private emitSubagentReturn(child: CodexSubagentIdentity, state: CodexRolloutState): void { + const key = `return:${child.id}` + if (state.emittedCollaborationEvents.has(key)) return + state.emittedCollaborationEvents.add(key) + this.delegate.emit({ + time: this.delegate.elapsed(), + type: 'subagent_return', + payload: { + parent: this.parentName(child.parentId, state), + parentId: child.parentId, + child: child.name, + childId: child.id, + summary: 'completed', + }, + }) + } + + private parentName(parentId: string, state: CodexRolloutState): string { + return state.subagentIdentities.get(parentId)?.name || parentId + } + + private handleWebSearchCall(payload: WebSearchCallPayload, state: CodexRolloutState): void { const query = String(payload.action?.query || '') if (!query) return // Web search is self-contained — emit start + end together. @@ -487,7 +820,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_start', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: 'WebSearch', args: query, preview: `WebSearch: ${query}`.slice(0, PREVIEW_MAX), @@ -498,7 +831,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'tool_call_end', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tool: 'WebSearch', result: payload.status || 'completed', tokenCost: 0, @@ -572,7 +905,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'message', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), role: 'thinking', content: text.slice(0, MESSAGE_MAX), }, @@ -623,7 +956,7 @@ export class CodexRolloutParser { time: this.delegate.elapsed(), type: 'context_update', payload: { - agent: ORCHESTRATOR_NAME, + agent: this.currentAgentId(state), tokens, breakdown: { ...bd }, ...(state.reportedContextWindow ? { tokensMax: state.reportedContextWindow } : {}), diff --git a/extension/src/codex-session-watcher.ts b/extension/src/codex-session-watcher.ts index 0c227a4d..79bfbe00 100644 --- a/extension/src/codex-session-watcher.ts +++ b/extension/src/codex-session-watcher.ts @@ -23,6 +23,7 @@ import { readNewFileLines } from './fs-utils' import { createLogger } from './logger' import { CodexRolloutParser, CodexRolloutState, createCodexRolloutState, + type CodexRolloutPrincipal, } from './codex-rollout-parser' import type { AgentSessionWatcher, SessionLifecycleEvent } from './session-runtime' import { TypedEventEmitter } from './typed-event-emitter' @@ -39,6 +40,10 @@ const SESSION_ID_FROM_FILENAME = /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([ interface WatchedCodexSession { sessionId: string + /** Stable root id shared by every rollout in one Codex thread family. */ + groupSessionId: string + agentName: string + isMain: boolean filePath: string fileWatcher: fs.FSWatcher | null pollTimer: NodeJS.Timeout | null @@ -56,6 +61,66 @@ interface WatchedCodexSession { parser: CodexRolloutParser } +/** Metadata available in the first session_meta record. Kept separately from + * live tail state so discovery can resolve a whole parent graph before parsing + * any rollout content. */ +interface CodexSessionMetadata { + sessionId: string + parentThreadId: string | null + cwd: string | null + filePath: string + stat: fs.Stats +} + +/** Minimal metadata needed to resolve a Codex rollout family. Exported so + * callers and tests can validate grouping without starting filesystem watches. */ +export interface CodexThreadMetadata { + sessionId: string + parentThreadId: string | null +} + +/** + * Resolve every rollout id to one stable family/root id using only explicit + * parent_thread_id metadata. A known parent with no rollout is retained as the + * conservative root; malformed cycles use their lowest observed id so every + * member still lands in one deterministic family. + */ +export function resolveCodexThreadGroups( + records: Iterable, +): Map { + const byId = new Map() + for (const record of records) byId.set(record.sessionId, record) + + const rootFor = (sessionId: string): string => { + const chain: string[] = [] + let current = sessionId + while (true) { + const cycleAt = chain.indexOf(current) + if (cycleAt >= 0) return chain.slice(cycleAt).sort()[0] + chain.push(current) + const metadata = byId.get(current) + if (!metadata?.parentThreadId) return current + const parentId = metadata.parentThreadId + if (!byId.has(parentId)) return parentId + current = parentId + } + } + + const groups = new Map() + for (const id of byId.keys()) groups.set(id, rootFor(id)) + return groups +} + +/** One visual session, which may be backed by several rollout files. */ +interface CodexSessionGroup { + sessionId: string + label: string + lifecycleEnded: boolean + completionEmitted: boolean + /** agent_spawn is idempotent at the group boundary, including placeholders. */ + emittedAgentIds: Set +} + function codexHome(): string { return process.env.CODEX_HOME || path.join(os.homedir(), '.codex') } @@ -87,7 +152,15 @@ function recentSessionDirs(now: Date): string[] { return Array.from(seen) } -/** Read the first line of a rollout file to extract cwd from session_meta. +function safeMetadataId(value: unknown): string | null { + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized && normalized.length <= 256 && !/[\r\n]/.test(normalized) + ? normalized + : null +} + +/** Read the first line of a rollout file to extract session_meta metadata. * * UTF-8 safety: `\n` is 0x0a, which never appears as a continuation byte in * a multi-byte UTF-8 sequence (continuation bytes are 0x80–0xBF), so slicing @@ -98,7 +171,7 @@ function recentSessionDirs(now: Date): string[] { * it far larger — keep reading in chunks until the first newline, up to a * 1MB cap. Past the cap we give up: JSON.parse fails on the truncated object * and we return null rather than emit a corrupted cwd. */ -function readSessionCwd(filePath: string): string | null { +function readSessionMetadata(filePath: string, fallbackSessionId: string): Pick | null { const CHUNK_SIZE = 65536 const MAX_FIRST_LINE = 1048576 try { @@ -121,9 +194,17 @@ function readSessionCwd(filePath: string): string | null { } const data = Buffer.concat(chunks) const line = data.subarray(0, end >= 0 ? end : data.length).toString('utf-8') - const parsed = JSON.parse(line) as { type?: string; payload?: { cwd?: string } } - if (parsed.type !== 'session_meta') return null - return typeof parsed.payload?.cwd === 'string' ? parsed.payload.cwd : null + const parsed = JSON.parse(line) as { + type?: string + payload?: { id?: unknown; session_id?: unknown; parent_thread_id?: unknown; cwd?: unknown } + } + if (parsed.type !== 'session_meta') return null + const payload = parsed.payload + return { + sessionId: safeMetadataId(payload?.id) || safeMetadataId(payload?.session_id) || fallbackSessionId, + parentThreadId: safeMetadataId(payload?.parent_thread_id), + cwd: typeof payload?.cwd === 'string' ? payload.cwd : null, + } } finally { fs.closeSync(fd) } } catch { return null } } @@ -133,6 +214,10 @@ function readSessionCwd(filePath: string): string | null { export class CodexSessionWatcher implements AgentSessionWatcher { private dirWatchers = new Map() private sessions = new Map() + /** All eligible rollout metadata seen during this watcher's lifetime. */ + private sessionMetadata = new Map() + /** Visual sessions keyed by their resolved root thread id. */ + private groups = new Map() private workspacePath: string | null = null private scanInterval: NodeJS.Timeout | null = null /** One-shot flag so the cwd-mismatch hint is logged at most once per process. */ @@ -159,25 +244,23 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } isSessionActive(sessionId: string): boolean { + const group = this.groups.get(sessionId) + if (group) return this.groupIsActive(group.sessionId) const s = this.sessions.get(sessionId) return !!s && s.sessionDetected && !s.sessionCompleted } getActiveSessions(): SessionInfo[] { - return Array.from(this.sessions.values()).map(s => ({ - id: s.sessionId, - label: s.label, - status: s.sessionCompleted ? 'completed' : 'active', - startTime: s.sessionStartTime, - lastActivityTime: s.lastActivityTime, - })) + return Array.from(this.groups.values()) + .map(group => this.sessionInfoForGroup(group)) + .filter((info): info is SessionInfo => info !== null) } replaySessionStart(sessionIds?: string[]): void { - for (const [id, session] of this.sessions) { - if (!session.sessionDetected) continue - if (sessionIds && !sessionIds.includes(id)) continue - this._onSessionLifecycle.fire({ type: 'started', sessionId: id, label: session.label }) + for (const group of this.groups.values()) { + if (!this.groupHasDetectedSession(group.sessionId)) continue + if (sessionIds && !sessionIds.includes(group.sessionId)) continue + this._onSessionLifecycle.fire({ type: 'started', sessionId: group.sessionId, label: group.label }) } } @@ -205,6 +288,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { private scanForSessions(): void { const now = new Date() let skippedByCwd = 0 + const candidates = new Map() for (const dir of recentSessionDirs(now)) { if (!fs.existsSync(dir)) continue @@ -217,13 +301,12 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } let entries: string[] - try { entries = fs.readdirSync(dir) } + try { entries = fs.readdirSync(dir).sort() } catch { continue } for (const name of entries) { if (!name.startsWith('rollout-') || !name.endsWith('.jsonl')) continue const filePath = path.join(dir, name) - if (this.sessions.has(this.sessionIdFor(filePath))) continue // Recency filter — skip stale files let stat: fs.Stats @@ -232,9 +315,12 @@ export class CodexSessionWatcher implements AgentSessionWatcher { const ageS = (Date.now() - stat.mtimeMs) / 1000 if (ageS > ACTIVE_SESSION_AGE_S) continue + const metadata = readSessionMetadata(filePath, this.sessionIdFor(filePath)) + if (!metadata) continue + // Workspace filter — only attach if cwd matches (or no workspace set) if (this.workspacePath) { - const cwd = readSessionCwd(filePath) + const cwd = metadata.cwd if (cwd === null) continue const resolvedCwd = this.resolvePath(cwd) if (!resolvedCwd || !this.pathMatchesWorkspace(resolvedCwd)) { @@ -243,10 +329,39 @@ export class CodexSessionWatcher implements AgentSessionWatcher { } } - this.attachSession(filePath, stat) + const discovered: CodexSessionMetadata = { ...metadata, filePath, stat } + this.sessionMetadata.set(discovered.sessionId, discovered) + if (!this.sessions.has(discovered.sessionId)) candidates.set(discovered.sessionId, discovered) } } + // Discovery is intentionally two-phase. A child file can sort before its + // root (or a grandchild before its parent); resolve every observed parent + // edge before attaching a parser so all events share the same group id. + const groupIds = resolveCodexThreadGroups(this.sessionMetadata.values()) + const depthFor = (metadata: CodexSessionMetadata): number => { + let depth = 0 + let current = metadata + const seen = new Set() + while (current.parentThreadId && !seen.has(current.sessionId)) { + seen.add(current.sessionId) + depth++ + const parent = this.sessionMetadata.get(current.parentThreadId) + if (!parent) break + current = parent + } + return depth + } + const ordered = Array.from(candidates.values()).sort((a, b) => { + const groupOrder = (groupIds.get(a.sessionId) || a.sessionId).localeCompare(groupIds.get(b.sessionId) || b.sessionId) + if (groupOrder !== 0) return groupOrder + const depthOrder = depthFor(a) - depthFor(b) + return depthOrder !== 0 ? depthOrder : a.sessionId.localeCompare(b.sessionId) + }) + for (const metadata of ordered) { + this.attachSession(metadata, groupIds.get(metadata.sessionId) || metadata.sessionId) + } + // Recent Codex activity exists but none of it belongs to this workspace — // the #1 reason users see no Codex events. Say so once, loudly enough to // survive the standalone app's default log level. @@ -280,28 +395,111 @@ export class CodexSessionWatcher implements AgentSessionWatcher { return candidate.startsWith(workspace + path.sep) } - private attachSession(filePath: string, stat: fs.Stats): void { - const sessionId = this.sessionIdFor(filePath) + private ensureGroup(groupSessionId: string): CodexSessionGroup { + let group = this.groups.get(groupSessionId) + if (!group) { + group = { + sessionId: groupSessionId, + label: `Codex ${groupSessionId.slice(0, SESSION_ID_DISPLAY)}`, + lifecycleEnded: false, + completionEmitted: false, + emittedAgentIds: new Set(), + } + this.groups.set(groupSessionId, group) + } + return group + } + + private sessionsInGroup(groupSessionId: string): WatchedCodexSession[] { + return Array.from(this.sessions.values()).filter(session => session.groupSessionId === groupSessionId) + } + + private groupIsActive(groupSessionId: string): boolean { + return this.sessionsInGroup(groupSessionId).some(session => session.sessionDetected && !session.sessionCompleted) + } + + private groupHasDetectedSession(groupSessionId: string): boolean { + return this.sessionsInGroup(groupSessionId).some(session => session.sessionDetected) + } + + private sessionInfoForGroup(group: CodexSessionGroup): SessionInfo | null { + const sessions = this.sessionsInGroup(group.sessionId) + if (sessions.length === 0) return null + return { + id: group.sessionId, + label: group.label, + status: this.groupIsActive(group.sessionId) ? 'active' : 'completed', + startTime: Math.min(...sessions.map(session => session.sessionStartTime)), + lastActivityTime: Math.max(...sessions.map(session => session.lastActivityTime)), + } + } + + /** Re-emit a rollout event against its family id. Stable agent_spawn ids are + * deduplicated here because Codex can describe the same child both in its + * parent's spawn result and in the child's own rollout. */ + private emitForGroup(session: WatchedCodexSession, event: AgentEvent): void { + if (event.type === 'agent_spawn') { + const id = typeof event.payload.id === 'string' ? event.payload.id : null + if (id) { + const group = this.ensureGroup(session.groupSessionId) + if (group.emittedAgentIds.has(id)) return + group.emittedAgentIds.add(id) + } + } + this._onEvent.fire({ ...event, sessionId: session.groupSessionId }) + } + + private principalFor(metadata: CodexSessionMetadata, groupSessionId: string): CodexRolloutPrincipal { + const rootMetadata = this.sessionMetadata.get(groupSessionId) + const rootHasRollout = !!rootMetadata && rootMetadata.parentThreadId === null + const isMain = metadata.sessionId === groupSessionId && rootHasRollout + return { + id: metadata.sessionId, + name: isMain ? ORCHESTRATOR_NAME : `Codex agent ${metadata.sessionId.slice(0, SESSION_ID_DISPLAY)}`, + isMain, + ...(metadata.parentThreadId ? { parentId: metadata.parentThreadId } : {}), + ...(!rootHasRollout ? { + rootPlaceholder: { id: groupSessionId, name: ORCHESTRATOR_NAME }, + } : {}), + } + } + + private attachSession(metadata: CodexSessionMetadata, groupSessionId: string): void { + const { sessionId, filePath, stat } = metadata + const group = this.ensureGroup(groupSessionId) + const principal = this.principalFor(metadata, groupSessionId) const label = `Codex ${sessionId.slice(0, SESSION_ID_DISPLAY)}` + const wasGroupActive = this.groupIsActive(groupSessionId) // Build the parser once per session so the delegate closures capture the // right session reference and re-emission is stateless on this side. const parser = new CodexRolloutParser({ - emit: (event) => this._onEvent.fire({ ...event, sessionId }), + emit: (event) => { + const s = this.sessions.get(sessionId) + if (s) this.emitForGroup(s, event) + }, elapsed: () => { const s = this.sessions.get(sessionId) return s ? (Date.now() - s.sessionStartTime) / 1000 : 0 }, setLabel: (newLabel) => { const s = this.sessions.get(sessionId) - if (!s || !s.label.startsWith('Codex ')) return // only replace auto-label + // A user message is useful as a title only for a real root rollout. + // Never derive the synthetic parent's name/title from a child prompt. + if (!s || !s.isMain || !s.label.startsWith('Codex ')) return s.label = newLabel - this._onSessionLifecycle.fire({ type: 'updated', sessionId, label: newLabel }) + const currentGroup = this.ensureGroup(s.groupSessionId) + currentGroup.label = newLabel + this._onSessionLifecycle.fire({ type: 'updated', sessionId: s.groupSessionId, label: newLabel }) }, + principal, }) const session: WatchedCodexSession = { sessionId, + groupSessionId, + agentName: principal.name, + isMain: principal.isMain, filePath, fileWatcher: null, pollTimer: null, @@ -322,8 +520,12 @@ export class CodexSessionWatcher implements AgentSessionWatcher { this.readNewLines(sessionId) session.sessionDetected = true - this._onSessionDetected.fire(sessionId) - this._onSessionLifecycle.fire({ type: 'started', sessionId, label }) + if (!wasGroupActive) { + group.lifecycleEnded = false + group.completionEmitted = false + this._onSessionDetected.fire(groupSessionId) + this._onSessionLifecycle.fire({ type: 'started', sessionId: groupSessionId, label: group.label }) + } try { session.fileWatcher = fs.watch(filePath, () => this.readNewLines(sessionId)) @@ -333,7 +535,7 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.pollTimer = setInterval(() => this.readNewLines(sessionId), POLL_FALLBACK_MS) this.resetInactivityTimer(sessionId) - log.info(`Attached to session ${sessionId.slice(0, SESSION_ID_DISPLAY)} at ${filePath}`) + log.info(`Attached to rollout ${sessionId.slice(0, SESSION_ID_DISPLAY)} in group ${groupSessionId.slice(0, SESSION_ID_DISPLAY)} at ${filePath}`) } private readNewLines(sessionId: string): void { @@ -346,11 +548,17 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.fileTail = result.tail session.lastActivityTime = Date.now() + const wasGroupActive = this.groupIsActive(session.groupSessionId) // Re-activate if the session had been marked complete on inactivity — // new content means the user resumed the Codex CLI. if (session.sessionCompleted) { session.sessionCompleted = false - this._onSessionLifecycle.fire({ type: 'started', sessionId, label: session.label }) + if (!wasGroupActive) { + const group = this.ensureGroup(session.groupSessionId) + group.lifecycleEnded = false + group.completionEmitted = false + this._onSessionLifecycle.fire({ type: 'started', sessionId: session.groupSessionId, label: group.label }) + } log.info(`Session ${sessionId.slice(0, SESSION_ID_DISPLAY)} re-activated after idle`) } @@ -369,13 +577,31 @@ export class CodexSessionWatcher implements AgentSessionWatcher { session.inactivityTimer = setTimeout(() => { if (session.sessionCompleted) return session.sessionCompleted = true - this._onEvent.fire({ - time: (Date.now() - session.sessionStartTime) / 1000, - type: 'agent_complete', - payload: { name: ORCHESTRATOR_NAME, sessionEnd: true }, - sessionId, - }) - this._onSessionLifecycle.fire({ type: 'ended', sessionId, label: session.label }) + // Child rollouts complete independently. A real root remains alive until + // the whole family is idle, otherwise the UI would complete active + // descendants when its own rollout pauses first. + if (!session.isMain) { + this.emitForGroup(session, { + time: (Date.now() - session.sessionStartTime) / 1000, + type: 'agent_complete', + payload: { id: session.sessionId, name: session.agentName }, + }) + } + if (!this.groupIsActive(session.groupSessionId)) { + const group = this.ensureGroup(session.groupSessionId) + if (!group.completionEmitted) { + group.completionEmitted = true + this.emitForGroup(session, { + time: (Date.now() - session.sessionStartTime) / 1000, + type: 'agent_complete', + payload: { id: session.groupSessionId, name: ORCHESTRATOR_NAME, sessionEnd: true }, + }) + } + if (!group.lifecycleEnded) { + group.lifecycleEnded = true + this._onSessionLifecycle.fire({ type: 'ended', sessionId: session.groupSessionId, label: group.label }) + } + } }, INACTIVITY_TIMEOUT_MS) } @@ -389,6 +615,8 @@ export class CodexSessionWatcher implements AgentSessionWatcher { if (s.inactivityTimer) clearTimeout(s.inactivityTimer) } this.sessions.clear() + this.sessionMetadata.clear() + this.groups.clear() this._onEvent.dispose() this._onSessionDetected.dispose() this._onSessionLifecycle.dispose() diff --git a/extension/test/codex-rollout-parser.test.ts b/extension/test/codex-rollout-parser.test.ts index aac38e41..f28d3645 100644 --- a/extension/test/codex-rollout-parser.test.ts +++ b/extension/test/codex-rollout-parser.test.ts @@ -273,4 +273,213 @@ describe('CodexRolloutParser', () => { assert.ok(Number.isFinite(state.contextBreakdown.userMessages)) assert.ok(Number.isFinite(state.contextBreakdown.toolResults)) }) + + it('emits one stable child identity from the modern spawn_agent result', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ type: 'session_meta', payload: { id: 'root-thread' } }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-1', + arguments: JSON.stringify({ task_name: 'researcher', model: 'future-codex-model', message: 'redacted task prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'spawn-1', + output: JSON.stringify({ agent_id: 'child-thread-1', nickname: 'researcher' }), + }, + }), state) + // Replayed output must not create a second child. A second call with the + // same stable identity is deliberately tolerated. + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-2', + arguments: JSON.stringify({ task_name: 'researcher', message: 'another prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'spawn-2', + output: JSON.stringify({ agent_id: 'child-thread-1', nickname: 'researcher' }), + }, + }), state) + + const spawns = events.filter(e => e.type === 'agent_spawn' && e.payload.name === 'researcher') + assert.equal(spawns.length, 1) + assert.equal(spawns[0].payload.id, 'child-thread-1') + assert.equal(spawns[0].payload.parentId, 'root-thread') + assert.equal(spawns[0].payload.task, 'researcher') + assert.equal(spawns[0].payload.model, 'future-codex-model') + assert.equal(events.filter(e => e.type === 'subagent_dispatch').length, 1) + assert.equal(JSON.stringify(events).includes('redacted task prompt'), false) + }) + + it('uses an opaque-id-derived name rather than task_name when spawn output has no nickname', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-no-nickname', + arguments: JSON.stringify({ task_name: 'private task title', message: 'private task prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'spawn-no-nickname', + output: JSON.stringify({ agent_id: 'child-thread-without-nickname' }), + }, + }), state) + + const spawn = events.find(event => event.type === 'agent_spawn' && event.payload.id === 'child-thread-without-nickname') + assert.ok(spawn) + assert.equal(spawn!.payload.name, 'Codex agent child-th') + assert.equal(JSON.stringify(spawn).includes('private task title'), false) + assert.equal(JSON.stringify(events).includes('private task prompt'), false) + }) + + it('fails closed for a legacy task_name-only spawn result', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-legacy', + arguments: JSON.stringify({ task_name: 'legacy-child', message: 'private prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'spawn-legacy', output: JSON.stringify({ task_name: 'legacy-child' }) }, + }), state) + assert.equal(events.some(e => e.type === 'agent_spawn' && e.payload.name === 'legacy-child'), false) + assert.equal(events.some(e => e.type === 'subagent_dispatch'), false) + assert.equal(JSON.stringify(events).includes('private prompt'), false) + }) + + it('does not create a relation for an unknown followup or wait target', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + for (const [name, callId, args, output] of [ + ['followup_task', 'unknown-followup', { target: 'missing-child', message: 'private' }, ''], + ['wait_agent', 'unknown-wait', { targets: ['missing-child'], timeout_ms: 1 }, JSON.stringify({ status: 'completed' })], + ] as const) { + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name, call_id: callId, arguments: JSON.stringify(args), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: callId, output, + } }), state) + } + assert.equal(events.some(e => e.type === 'subagent_dispatch'), false) + assert.equal(events.some(e => e.type === 'subagent_return'), false) + }) + + it('treats duplicate nicknames as ambiguous for nominal relations', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + for (const [callId, id] of [['spawn-a', 'child-a'], ['spawn-b', 'child-b']] as const) { + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name: 'spawn_agent', call_id: callId, + arguments: JSON.stringify({ task_name: 'same-name', message: 'private' }), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: callId, + output: JSON.stringify({ agent_id: id, nickname: 'same-name' }), + } }), state) + } + const dispatchesAfterSpawn = events.filter(e => e.type === 'subagent_dispatch').length + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name: 'followup_task', call_id: 'ambiguous-followup', + arguments: JSON.stringify({ target: 'same-name', message: 'private' }), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: 'ambiguous-followup', output: '', + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call', name: 'wait_agent', call_id: 'ambiguous-wait', + arguments: JSON.stringify({ targets: ['same-name'], timeout_ms: 1 }), + } }), state) + parser.processLine(JSON.stringify({ type: 'response_item', payload: { + type: 'function_call_output', call_id: 'ambiguous-wait', output: '', + } }), state) + assert.equal(dispatchesAfterSpawn, 2) + assert.equal(events.filter(e => e.type === 'subagent_dispatch').length, dispatchesAfterSpawn) + assert.equal(events.some(e => e.type === 'subagent_return'), false) + }) + + it('fails closed when spawn success has no reliable identity', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'spawn-unknown', + arguments: JSON.stringify({ message: 'do not use this as identity' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call_output', call_id: 'spawn-unknown', output: 'accepted' }, + }), state) + assert.equal(events.some(e => e.type === 'agent_spawn' && e.payload.name !== 'orchestrator'), false) + assert.equal(events.some(e => e.type === 'subagent_dispatch'), false) + }) + + it('uses rollout thread metadata for nested principal and direct-child hierarchy', () => { + const events: AgentEvent[] = [] + const parser = new CodexRolloutParser({ emit: (e) => events.push(e), elapsed: () => 0 }) + const state = createCodexRolloutState() + parser.processLine(JSON.stringify({ + type: 'session_meta', + payload: { id: 'child-thread', parent_thread_id: 'root-thread' }, + }), state) + const placeholder = events.find(e => e.type === 'agent_spawn' && e.payload.id === 'root-thread') + assert.ok(placeholder) + assert.equal(placeholder!.payload.isMain, true) + assert.equal(placeholder!.payload.placeholder, true) + const principal = events.find(e => e.type === 'agent_spawn' && e.payload.id === 'child-thread') + assert.ok(principal) + assert.equal(principal!.payload.id, 'child-thread') + assert.equal(principal!.payload.parentId, 'root-thread') + assert.equal(principal!.payload.isMain, false) + + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', name: 'spawn_agent', call_id: 'grandchild-call', + arguments: JSON.stringify({ task_name: 'grandchild', message: 'nested private prompt' }), + }, + }), state) + parser.processLine(JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', call_id: 'grandchild-call', + output: JSON.stringify({ agent_id: 'grandchild-thread', nickname: 'grandchild' }), + }, + }), state) + + const grandchild = events.find(e => e.type === 'agent_spawn' && e.payload.name === 'grandchild') + assert.ok(grandchild) + assert.equal(grandchild!.payload.id, 'grandchild-thread') + assert.equal(grandchild!.payload.parentId, 'child-thread') + const dispatch = events.find(e => e.type === 'subagent_dispatch' && e.payload.child === 'grandchild') + assert.ok(dispatch) + assert.equal(dispatch!.payload.parentId, 'child-thread') + const principalEvents = events.filter(e => e.type === 'tool_call_start' || e.type === 'tool_call_end' || e.type === 'context_update') + for (const event of principalEvents) assert.equal(event.payload.agent, 'child-thread') + assert.equal(JSON.stringify(events).includes('nested private prompt'), false) + }) }) diff --git a/extension/test/codex-session-watcher.test.ts b/extension/test/codex-session-watcher.test.ts new file mode 100644 index 00000000..4f35274b --- /dev/null +++ b/extension/test/codex-session-watcher.test.ts @@ -0,0 +1,179 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { CodexSessionWatcher, resolveCodexThreadGroups } from '../src/codex-session-watcher' +import type { AgentEvent } from '../src/protocol' +import type { SessionLifecycleEvent } from '../src/session-runtime' + +function stableId(n: number): string { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` +} + +function dayDir(home: string): string { + const now = new Date() + return path.join( + home, + 'sessions', + String(now.getFullYear()), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + ) +} + +interface RolloutFixture { + id: string + parentId?: string + /** This is only a filename sort key; mtime remains current for discovery. */ + timestamp: string +} + +function collectFamily(rollouts: RolloutFixture[]): { + events: AgentEvent[] + lifecycle: SessionLifecycleEvent[] + detected: string[] + sessions: ReturnType +} { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-flow-codex-family-')) + const home = path.join(temp, 'codex-home') + const workspace = path.join(temp, 'workspace') + fs.mkdirSync(workspace) + const rolloutDir = dayDir(home) + fs.mkdirSync(rolloutDir, { recursive: true }) + for (const rollout of rollouts) { + const name = `rollout-${rollout.timestamp}-${rollout.id}.jsonl` + const payload = { + id: rollout.id, + cwd: workspace, + ...(rollout.parentId ? { parent_thread_id: rollout.parentId } : {}), + } + fs.writeFileSync(path.join(rolloutDir, name), `${JSON.stringify({ type: 'session_meta', payload })}\n`) + } + + const previousCodexHome = process.env.CODEX_HOME + process.env.CODEX_HOME = home + const watcher = new CodexSessionWatcher(workspace) + const events: AgentEvent[] = [] + const lifecycle: SessionLifecycleEvent[] = [] + const detected: string[] = [] + const eventSubscription = watcher.onEvent(event => events.push(event)) + const lifecycleSubscription = watcher.onSessionLifecycle(event => lifecycle.push(event)) + const detectedSubscription = watcher.onSessionDetected(id => detected.push(id)) + try { + watcher.start() + return { events, lifecycle, detected, sessions: watcher.getActiveSessions() } + } finally { + eventSubscription.dispose() + lifecycleSubscription.dispose() + detectedSubscription.dispose() + watcher.dispose() + if (previousCodexHome === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = previousCodexHome + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +function spawns(events: AgentEvent[]): AgentEvent[] { + return events.filter(event => event.type === 'agent_spawn') +} + +describe('CodexSessionWatcher family grouping', () => { + it('exports pure parent-chain grouping without creating a watcher or fs.watch handle', () => { + const root = stableId(100) + const child = stableId(101) + const grandchild = stableId(102) + const groups = resolveCodexThreadGroups([ + { sessionId: grandchild, parentThreadId: child }, + { sessionId: child, parentThreadId: root }, + { sessionId: root, parentThreadId: null }, + ]) + + assert.deepEqual(Array.from(groups.entries()), [ + [grandchild, root], + [child, root], + [root, root], + ]) + }) + + it('groups three direct child rollouts under one discovered root despite adversarial filename order', () => { + const root = stableId(1) + const children = [stableId(2), stableId(3), stableId(4)] + // The root sorts after every child. Discovery must still parse root metadata + // before emitting any event, then attach by hierarchy depth. + const result = collectFamily([ + ...children.map((id, index) => ({ id, parentId: root, timestamp: `2026-09-05T09-00-0${index}` })), + { id: root, timestamp: '2026-09-05T23-59-59' }, + ]) + + assert.deepEqual(result.sessions.map(session => session.id), [root]) + assert.deepEqual(result.detected, [root]) + assert.equal(result.lifecycle.filter(event => event.type === 'started').length, 1) + assert.ok(result.events.every(event => event.sessionId === root)) + + const family = spawns(result.events) + assert.equal(family.length, 4) + assert.ok(family.some(event => event.payload.id === root && event.payload.isMain === true)) + for (const child of children) { + const spawn = family.find(event => event.payload.id === child) + assert.ok(spawn) + assert.equal(spawn!.payload.isMain, false) + assert.equal(spawn!.payload.parentId, root) + assert.equal(typeof spawn!.payload.task, 'undefined') + } + }) + + it('resolves a grandchild through its rollout parent chain to the same root and preserves both edges', () => { + const root = stableId(10) + const child = stableId(11) + const grandchild = stableId(12) + const result = collectFamily([ + { id: grandchild, parentId: child, timestamp: '2026-09-05T09-00-00' }, + { id: child, parentId: root, timestamp: '2026-09-05T10-00-00' }, + { id: root, timestamp: '2026-09-05T11-00-00' }, + ]) + + assert.deepEqual(result.sessions.map(session => session.id), [root]) + assert.ok(result.events.every(event => event.sessionId === root)) + const childSpawn = spawns(result.events).find(event => event.payload.id === child) + const grandchildSpawn = spawns(result.events).find(event => event.payload.id === grandchild) + assert.ok(childSpawn) + assert.ok(grandchildSpawn) + // The web simulation turns these parentId fields into root→child→grandchild edges. + assert.equal(childSpawn!.payload.parentId, root) + assert.equal(grandchildSpawn!.payload.parentId, child) + assert.equal(childSpawn!.payload.isMain, false) + assert.equal(grandchildSpawn!.payload.isMain, false) + }) + + it('emits exactly one observed-parent placeholder when the root has no rollout', () => { + const missingRoot = stableId(20) + const child = stableId(21) + const result = collectFamily([{ id: child, parentId: missingRoot, timestamp: '2026-09-05T12-00-00' }]) + + assert.deepEqual(result.sessions.map(session => session.id), [missingRoot]) + assert.deepEqual(result.detected, [missingRoot]) + const family = spawns(result.events) + assert.equal(family.length, 2) + const placeholder = family.find(event => event.payload.id === missingRoot) + const childSpawn = family.find(event => event.payload.id === child) + assert.ok(placeholder) + assert.ok(childSpawn) + assert.equal(placeholder!.payload.placeholder, true) + assert.equal(placeholder!.payload.isMain, true) + assert.equal(childSpawn!.payload.isMain, false) + assert.equal(childSpawn!.payload.parentId, missingRoot) + }) + + it('keeps an unparented rollout as its own ordinary session', () => { + const root = stableId(30) + const result = collectFamily([{ id: root, timestamp: '2026-09-05T13-00-00' }]) + + assert.deepEqual(result.sessions.map(session => session.id), [root]) + const family = spawns(result.events) + assert.equal(family.length, 1) + assert.equal(family[0].payload.id, root) + assert.equal(family[0].payload.isMain, true) + assert.equal(family[0].payload.placeholder, undefined) + }) +}) diff --git a/package.json b/package.json index 1cd0c2fb..9bb2ae7d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build:webview": "pnpm --filter agent-flow-web run build:webview", "build:all": "pnpm run build:webview && pnpm run build:extension", "build:app": "node app/build.js", - "test": "node --import tsx --test \"scripts/**/*.test.ts\" \"app/src/**/*.test.ts\"" + "test": "node --import tsx --test \"scripts/**/*.test.ts\" \"app/src/**/*.test.ts\" && node scripts/run-workspace-tests.js" }, "devDependencies": { "concurrently": "^9.2.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 432a7320..2ebfdd28 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ packages: - extension - web +allowBuilds: + esbuild: true + sharp: true diff --git a/scripts/run-workspace-tests.js b/scripts/run-workspace-tests.js new file mode 100644 index 00000000..22370710 --- /dev/null +++ b/scripts/run-workspace-tests.js @@ -0,0 +1,33 @@ +const { spawnSync } = require('node:child_process') +const path = require('node:path') + +const root = path.resolve(__dirname, '..') +const suites = [ + { + name: 'web', + cwd: path.join(root, 'web'), + args: ['--import', 'tsx', '--test', 'hooks/**/*.test.ts', 'lib/**/*.test.ts'], + }, + { + name: 'extension', + cwd: path.join(root, 'extension'), + args: ['--import', 'tsx', '--test', 'test/**/*.test.ts'], + }, +] + +for (const suite of suites) { + console.log(`\n[test] ${suite.name}`) + const result = spawnSync(process.execPath, suite.args, { + cwd: suite.cwd, + stdio: 'inherit', + shell: false, + }) + + if (result.error) { + console.error(`[test] ${suite.name} failed to start: ${result.error.message}`) + process.exit(1) + } + if (result.status !== 0) { + process.exit(result.status ?? 1) + } +} diff --git a/scripts/telemetry.test.ts b/scripts/telemetry.test.ts index 937bfe24..596f7420 100644 --- a/scripts/telemetry.test.ts +++ b/scripts/telemetry.test.ts @@ -9,13 +9,15 @@ function setup() { return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-flow-tel-')) } -function makeClient(dir: string) { +function makeClient(dir: string, env: NodeJS.ProcessEnv = {}, fetch?: typeof globalThis.fetch) { return createTelemetryClient({ logDir: path.join(dir, 'telemetry'), installIdPath: path.join(dir, 'installation-id'), // Unroutable so tests never hit the real endpoint. endpoint: 'http://127.0.0.1:1', apiKey: 'test', + env, + fetch, }) } @@ -34,8 +36,8 @@ test('hardcoded constants are present', () => { assert.match(TELEMETRY_PUBLISHABLE_KEY, /^sb_publishable_/) }) -test('isTelemetryEnabled: default (no env) is true', () => { - assert.equal(isTelemetryEnabled({}), true) +test('isTelemetryEnabled: default (no env) is false', () => { + assert.equal(isTelemetryEnabled({}), false) }) test('isTelemetryEnabled: AGENT_FLOW_TELEMETRY=false disables', () => { @@ -47,7 +49,8 @@ test('isTelemetryEnabled: AGENT_FLOW_TELEMETRY=false disables', () => { test('isTelemetryEnabled: AGENT_FLOW_TELEMETRY=true stays enabled', () => { assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: 'true' }), true) - assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: '1' }), true) + assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: 'TRUE' }), true) + assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: '1' }), false) }) test('isTelemetryEnabled: DO_NOT_TRACK=1 disables', () => { @@ -59,11 +62,25 @@ test('isTelemetryEnabled: DO_NOT_TRACK wins even when AGENT_FLOW_TELEMETRY=true' assert.equal(isTelemetryEnabled({ AGENT_FLOW_TELEMETRY: 'true', DO_NOT_TRACK: '1' }), false) }) +test('disabled by default creates no state and does not call transport', async () => { + const dir = setup() + let transportCalls = 0 + const fetch = (() => { + transportCalls += 1 + return Promise.reject(new Error('transport must not be called')) + }) as typeof globalThis.fetch + const client = makeClient(dir, {}, fetch) + await client.init() + client.emit(baseEvent()) + await client.dispose() + assert.equal(transportCalls, 0) + assert.equal(fs.existsSync(path.join(dir, 'telemetry')), false) + assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) +}) + test('emit appends to JSONL when enabled', async () => { const dir = setup() - const client = makeClient(dir) - delete process.env.AGENT_FLOW_TELEMETRY - delete process.env.DO_NOT_TRACK + const client = makeClient(dir, { AGENT_FLOW_TELEMETRY: 'true' }) await client.init() client.emit(baseEvent()) await client.dispose() @@ -79,47 +96,36 @@ test('emit appends to JSONL when enabled', async () => { test('disabled via AGENT_FLOW_TELEMETRY=false writes nothing to disk', async () => { const dir = setup() - process.env.AGENT_FLOW_TELEMETRY = 'false' - try { - const client = makeClient(dir) - await client.init() - client.emit(baseEvent()) - await client.dispose() - // No events log AND no install-id file — disabled means zero disk footprint. - assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) - assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) - } finally { - delete process.env.AGENT_FLOW_TELEMETRY - } + const client = makeClient(dir, { AGENT_FLOW_TELEMETRY: 'false' }) + await client.init() + client.emit(baseEvent()) + await client.dispose() + // No events log AND no install-id file — disabled means zero disk footprint. + assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) + assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) }) test('disabled via DO_NOT_TRACK=1 writes nothing to disk', async () => { const dir = setup() - process.env.DO_NOT_TRACK = '1' - try { - const client = makeClient(dir) - await client.init() - client.emit(baseEvent()) - await client.dispose() - assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) - assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) - } finally { - delete process.env.DO_NOT_TRACK - } + const client = makeClient(dir, { DO_NOT_TRACK: '1', AGENT_FLOW_TELEMETRY: 'true' }) + await client.init() + client.emit(baseEvent()) + await client.dispose() + assert.equal(fs.existsSync(path.join(dir, 'telemetry', 'events.jsonl')), false) + assert.equal(fs.existsSync(path.join(dir, 'installation-id')), false) }) test('install-id persists across init calls', async () => { const dir = setup() - delete process.env.AGENT_FLOW_TELEMETRY - delete process.env.DO_NOT_TRACK - const client1 = makeClient(dir) + const env = { AGENT_FLOW_TELEMETRY: 'true' } + const client1 = makeClient(dir, env) await client1.init() client1.emit(baseEvent()) await client1.dispose() const idPath = path.join(dir, 'installation-id') const id1 = fs.readFileSync(idPath, 'utf-8').trim() - const client2 = makeClient(dir) + const client2 = makeClient(dir, env) await client2.init() client2.emit(baseEvent()) await client2.dispose() @@ -131,9 +137,7 @@ test('install-id persists across init calls', async () => { test('emit sanitizes session_id', async () => { const dir = setup() - delete process.env.AGENT_FLOW_TELEMETRY - delete process.env.DO_NOT_TRACK - const client = makeClient(dir) + const client = makeClient(dir, { AGENT_FLOW_TELEMETRY: 'true' }) await client.init() client.emit({ ...baseEvent(), session_id: 'quote"backslash\\newline\n' }) await client.dispose() diff --git a/scripts/telemetry.ts b/scripts/telemetry.ts index 4183d38b..bbe674d4 100644 --- a/scripts/telemetry.ts +++ b/scripts/telemetry.ts @@ -8,7 +8,9 @@ import { syncOnce } from './telemetry/sync' * Hardcoded telemetry endpoint + publishable key. * * These ship inside every published binary. No env var override, no runtime - * fallback. All enabled installs send events to Agent Flow's Supabase project. + * fallback. Telemetry is disabled by default; only installs that explicitly + * opt in with AGENT_FLOW_TELEMETRY=true send events to Agent Flow's Supabase + * project. * Forks that republish under a different name must edit these constants and * rebuild. * @@ -32,8 +34,6 @@ const FIRST_SYNC_DELAY_MS = 2 * 1000 const SYNC_SCHEDULE_MS = [2 * 60 * 1000, 3 * 60 * 1000] const SYNC_REPEAT_MS = 5 * 60 * 1000 -const FALSY_VALUES = new Set(['false', '0', 'disabled', '']) - export interface TelemetryEvent { event_type: 'session_start' | 'session_end' | 'error' session_id: string @@ -63,6 +63,8 @@ export interface TelemetryClientOptions { endpoint?: string /** Override the key for tests. Defaults to the hardcoded constant. */ apiKey?: string + /** Override the transport for tests. Defaults to global fetch. */ + fetch?: typeof fetch } export interface TelemetryClient { @@ -81,19 +83,15 @@ export interface TelemetryClient { * * Rules: * - `DO_NOT_TRACK` truthy → disabled (wins over everything) - * - `AGENT_FLOW_TELEMETRY` falsy (`false`, `0`, `disabled`, ``) → disabled - * - Otherwise enabled (including when AGENT_FLOW_TELEMETRY is unset) + * - `AGENT_FLOW_TELEMETRY=true` (case-insensitive) → enabled + * - Any other value, including when unset, → disabled */ export function isTelemetryEnabled(env: NodeJS.ProcessEnv): boolean { const dnt = env.DO_NOT_TRACK if (dnt !== undefined && dnt !== '' && dnt !== '0' && dnt.toLowerCase() !== 'false') { return false } - const flag = env.AGENT_FLOW_TELEMETRY - if (flag !== undefined && FALSY_VALUES.has(flag.toLowerCase())) { - return false - } - return true + return env.AGENT_FLOW_TELEMETRY?.trim().toLowerCase() === 'true' } export function createTelemetryClient(opts: TelemetryClientOptions): TelemetryClient { @@ -143,7 +141,7 @@ export function createTelemetryClient(opts: TelemetryClientOptions): TelemetryCl function fireSync() { if (syncInFlight) return // another sync is already running — skip, not queue - syncInFlight = syncOnce({ jsonlPath, cursorPath, endpoint, apiKey }) + syncInFlight = syncOnce({ jsonlPath, cursorPath, endpoint, apiKey, fetch: opts.fetch }) .catch(() => { // Swallow — next tick retries from the same cursor position. }) @@ -200,7 +198,7 @@ export function createTelemetryClient(opts: TelemetryClientOptions): TelemetryCl // starts from an up-to-date cursor. if (syncInFlight) { try { await syncInFlight } catch { /* best effort */ } } if (enabled()) { - try { await syncOnce({ jsonlPath, cursorPath, endpoint, apiKey }) } catch { /* best effort */ } + try { await syncOnce({ jsonlPath, cursorPath, endpoint, apiKey, fetch: opts.fetch }) } catch { /* best effort */ } } }, } diff --git a/web/components/agent-visualizer/index.tsx b/web/components/agent-visualizer/index.tsx index ca88a0c8..fe4e7e96 100644 --- a/web/components/agent-visualizer/index.tsx +++ b/web/components/agent-visualizer/index.tsx @@ -24,6 +24,10 @@ import { MOCK_DURATION } from "@/lib/mock-scenario" import { MessageFeedPanel } from "./message-feed-panel" import { TopBar } from "./top-bar" import { useAudioEffects } from "@/hooks/use-audio-effects" +import { OfficeView } from "./office" +import { officeAgentId, projectOfficeGraph } from "@/lib/office" + +type VisualizerMode = 'office' | 'graph' export function AgentVisualizer() { const bridge = useVSCodeBridge() @@ -63,6 +67,35 @@ export function AgentVisualizer() { const selection = useSelectionState({ agents, toolCalls, discoveries }) + const [viewMode, setViewMode] = useState('office') + + // Office uses a privacy-scoped projection, while graph selection remains + // keyed by the original simulation agent IDs. + const officeProjection = useMemo(() => projectOfficeGraph({ + sessionId: bridge.selectedSessionId ?? undefined, + agents: agents.values(), + edges, + }), [bridge.selectedSessionId, agents, edges]) + const officeSessions = useMemo(() => bridge.sessions.map(session => ({ + ...session, + // Session labels may be prompt-derived. Office exposes only a short ID. + label: `Session ${session.id.slice(0, 8) || 'unknown'}`, + })), [bridge.sessions]) + const officeToGraphAgentId = useMemo(() => { + const mapping = new Map() + for (const graphAgentId of agents.keys()) { + mapping.set(officeAgentId(bridge.selectedSessionId ?? undefined, graphAgentId), graphAgentId) + } + return mapping + }, [bridge.selectedSessionId, agents]) + const officeSelectedAgentId = selection.selectedAgentId + ? officeAgentId(bridge.selectedSessionId ?? undefined, selection.selectedAgentId) + : null + const handleOfficeAgentSelect = useCallback((officeId: string) => { + const graphAgentId = officeToGraphAgentId.get(officeId) + if (graphAgentId) selection.handleAgentClick(graphAgentId) + }, [officeToGraphAgentId, selection.handleAgentClick]) + const [showStats, setShowStats] = useState(false) const [showHexGrid, setShowHexGrid] = useState(true) const [showCostOverlay, setShowCostOverlay] = useState(false) @@ -269,85 +302,126 @@ export function AgentVisualizer() { )} - {/* Canvas fills everything */} - - - {/* Message feed panel (top-left) */} - - - {/* Agent detail card (floating, tethered to node) */} - {selectedAgent && selection.selectedAgentWorldPos && ( -
- + ) : ( + <> + {/* Canvas fills everything */} + -
- )} - {/* Tool call detail popup */} - {selection.selectedToolData && selection.selectedToolScreenPos && ( -
- -
- )} - {/* Discovery detail popup */} - {selection.selectedDiscoveryData && selection.selectedDiscoveryScreenPos && ( -
- + +
+ )} + + {/* Tool call detail popup */} + {selection.selectedToolData && selection.selectedToolScreenPos && ( +
+ +
+ )} + + {/* Discovery detail popup */} + {selection.selectedDiscoveryData && selection.selectedDiscoveryScreenPos && ( +
+ +
+ )} + + {/* Chat panel (bottom-right, shown when agent selected) */} + - - )} - - {/* Chat panel (bottom-right, shown when agent selected) */} - - {/* Context menu */} - {selection.contextMenu && ( - selection.setContextMenu(null)} - /> + {/* Context menu */} + {selection.contextMenu && ( + selection.setContextMenu(null)} + /> + )} + )} + {/* Accessible, reversible view selector. Office is the default. */} +
+ {(['office', 'graph'] as const).map(mode => { + const isActive = viewMode === mode + return ( + + ) + })} +
+ {/* Floating control strip */} = { + unknown: 'Sin clasificar', + planning: 'Planificando', + researching: 'Investigando', + editing: 'Editando', + executing: 'Ejecutando', + waiting_approval: 'Esperando aprobación', + blocked: 'Bloqueado', + completed: 'Completado', + idle: 'Disponible', + stale: 'Sin actividad reciente', +} + +function roomForZone(zone: OfficeZone): RoomId { + switch (zone) { + case 'planning': return 'meetings' + case 'researching': return 'library' + case 'editing': return 'desks' + case 'executing': return 'lab' + case 'waiting_approval': return 'decisions' + case 'blocked': return 'incidents' + case 'completed': return 'deliveries' + case 'idle': + case 'stale': return 'entrance' + case 'unknown': return 'entrance' + } +} + +function placeAgents(agents: readonly OfficeAgent[]): PlacedAgent[] { + const grouped = new Map() + for (const room of ROOMS) grouped.set(room.id, []) + for (const agent of agents) grouped.get(roomForZone(agent.zone))?.push(agent) + + const placements: PlacedAgent[] = [] + for (const room of ROOMS) { + const occupants = [...(grouped.get(room.id) ?? [])] + .sort((left, right) => left.slot - right.slot || left.id.localeCompare(right.id)) + const columns = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(occupants.length)))) + const rows = Math.max(1, Math.ceil(occupants.length / columns)) + + occupants.forEach((agent, index) => { + const column = index % columns + const row = Math.floor(index / columns) + // Keep the avatar well inside its room even when many agents arrive at once. + const x = room.x + room.width * ((column + 1) / (columns + 1)) + const y = room.y + 48 + (room.height - 68) * ((row + 0.55) / rows) + placements.push({ agent, room, x, y }) + }) + } + return placements +} + +function initials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean) + return (words.slice(0, 2).map(word => word[0]).join('') || 'A').toUpperCase() +} + +function avatarHue(key: string): number { + let value = 0 + for (let index = 0; index < key.length; index += 1) value = (value * 31 + key.charCodeAt(index)) % 360 + return value +} + +export interface OfficeViewProps { + /** Privacy-scoped source. This is the preferred Office contract. */ + projection?: OfficeProjection + /** Alternative Office contract when the caller already holds the two parts. */ + agents?: ReadonlyMap + edges?: readonly OfficeEdge[] + selectedAgentId?: string | null + onSelectAgent?: (agentId: string) => void + onClearSelection?: () => void + className?: string + ariaLabel?: string +} + +/** + * Read-only office representation of the current agent graph. + * It deliberately has no simulation or mutation controls: selecting an agent is + * delegated to its parent so the existing selection state remains authoritative. + */ +export function OfficeView({ + projection, + agents: agentMap, + edges: suppliedEdges, + selectedAgentId = null, + onSelectAgent, + onClearSelection, + className, + ariaLabel = 'Oficina de agentes', +}: OfficeViewProps) { + const agents = useMemo(() => Array.from((projection?.agents ?? agentMap ?? new Map()).values()), [projection, agentMap]) + const placements = useMemo(() => placeAgents(agents), [agents]) + const placementById = useMemo(() => new Map(placements.map(placement => [placement.agent.id, placement])), [placements]) + const hierarchy = useMemo( + () => (projection?.edges ?? suppliedEdges ?? []).filter(edge => placementById.has(edge.parentId) && placementById.has(edge.childId)), + [projection, suppliedEdges, placementById], + ) + const selected = selectedAgentId ? placementById.get(selectedAgentId)?.agent ?? null : null + + const selectAt = (nextIndex: number) => { + const next = placements[(nextIndex + placements.length) % placements.length] + if (next) onSelectAgent?.(next.agent.id) + } + + const handleAgentKeyDown = (event: KeyboardEvent, agentId: string) => { + const index = placements.findIndex(placement => placement.agent.id === agentId) + if (index < 0) return + + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + event.preventDefault() + selectAt(index + 1) + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + event.preventDefault() + selectAt(index - 1) + } else if (event.key === 'Home') { + event.preventDefault() + selectAt(0) + } else if (event.key === 'End') { + event.preventDefault() + selectAt(placements.length - 1) + } else if (event.key === 'Escape') { + event.preventDefault() + onClearSelection?.() + } + } + + return ( +
+
+ {selected ? `${selected.name}: ${STATE_LABELS[selected.state]}` : `${agents.length} agentes en la oficina`} +
+ +
+ + + + +
+ {placements.map(({ agent, room, x, y }) => ( + + ))} +
+
+ +
+ {placements.map(({ agent, room }) => ( + + ))} +
+ + {selected && ( + + )} +
+ ) +} + +interface OfficeAgentProps { + agent: OfficeAgent + room: RoomDefinition + isSelected: boolean + onSelect?: (agentId: string) => void + onKeyDown: (event: KeyboardEvent, agentId: string) => void + style?: CSSProperties +} + +function OfficeAgent({ agent, room, isSelected, onSelect, onKeyDown, style }: OfficeAgentProps) { + const stateLabel = STATE_LABELS[agent.state] + return ( + + ) +} diff --git a/web/components/agent-visualizer/office/index.ts b/web/components/agent-visualizer/office/index.ts new file mode 100644 index 00000000..5dc4e4c0 --- /dev/null +++ b/web/components/agent-visualizer/office/index.ts @@ -0,0 +1 @@ +export { OfficeView, type OfficeViewProps } from './OfficeView' diff --git a/web/components/agent-visualizer/office/office.module.css b/web/components/agent-visualizer/office/office.module.css new file mode 100644 index 00000000..cfa9bd8c --- /dev/null +++ b/web/components/agent-visualizer/office/office.module.css @@ -0,0 +1,152 @@ +.office { + --ink: #18213b; + --muted: #60708f; + --paper: #f7f2e8; + --line: #d6ccb9; + --blue: #3c72b7; + position: relative; + width: 100%; + min-height: 390px; + overflow: hidden; + border: 1px solid rgba(67, 75, 96, .25); + border-radius: 16px; + background: #e6edf2; + color: var(--ink); + font-family: ui-rounded, "SF Pro Rounded", "Segoe UI", sans-serif; +} + +.stage { + position: relative; + width: 100%; + height: 100%; + aspect-ratio: 1000 / 630; + min-height: 0; + background: + linear-gradient(135deg, rgba(255, 255, 255, .42), transparent 42%), + repeating-linear-gradient(45deg, rgba(64, 98, 122, .035) 0 1px, transparent 1px 10px), + #dce8ea; +} + +.floor, .edges, .agentLayer { position: absolute; inset: 0; } +.edges { z-index: 2; overflow: visible; pointer-events: none; } +.agentLayer { z-index: 3; } + +.room { + position: absolute; + left: calc(var(--room-x) * 0.1%); + top: calc(var(--room-y) / 6.3 * 1%); + width: calc(var(--room-w) * 0.1%); + height: calc(var(--room-h) / 6.3 * 1%); + overflow: hidden; + border: 2px solid #b8aa92; + border-radius: 8px 8px 14px 14px; + background: + linear-gradient(135deg, rgba(255, 255, 255, .55), transparent 44%), + repeating-linear-gradient(90deg, rgba(148, 123, 91, .08) 0 1px, transparent 1px 13px), + var(--paper); + box-shadow: 0 5px 0 #c5baa9, 0 10px 15px rgba(48, 57, 71, .12); +} + +.room::after { + position: absolute; + right: 8%; + bottom: 10%; + width: 28%; + height: 14%; + content: ''; + border: 2px solid rgba(103, 118, 109, .5); + border-radius: 3px; + background: rgba(145, 181, 150, .2); + box-shadow: -9px -8px 0 -3px #6f9d75, -9px -8px 0 -1px rgba(49, 86, 58, .5); +} + +.roomLabel, .roomHint { position: absolute; left: 10px; z-index: 1; } +.roomLabel { top: 9px; color: #6f5d48; font-size: clamp(9px, 1vw, 13px); font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.roomHint { top: 26px; color: #887b6c; font-size: clamp(7px, .75vw, 10px); } +.roomFurniture { position: absolute; left: 11%; bottom: 10%; width: 30%; height: 17%; border: 2px solid rgba(116, 91, 67, .55); border-radius: 4px; background: rgba(164, 125, 84, .18); } +.room_meetings .roomFurniture { width: 50%; left: 25%; border-radius: 50%; } +.room_library .roomFurniture { width: 48%; height: 23%; border-radius: 2px; box-shadow: inset 0 4px #c29368, inset 0 8px #7991ae, inset 0 12px #bb7c71; } +.room_lab .roomFurniture { background: rgba(119, 181, 191, .18); } +.room_incidents { background: linear-gradient(135deg, #fff7e9, #f6e7dc); } +.room_decisions { background: linear-gradient(135deg, #f7efff, #ebe2f7); } +.room_deliveries { background: linear-gradient(135deg, #eff8ef, #e3f0e3); } + +.edge { fill: none; stroke: #7766a9; stroke-dasharray: 5 5; stroke-linecap: round; stroke-width: 1.7; animation: office-dash 1.8s linear infinite; } +.edgeArrow { fill: #7766a9; } + +.agent { + --accent: #3c72b7; + position: absolute; + left: var(--agent-x); + top: var(--agent-y); + display: grid; + justify-items: center; + width: min(13vw, 110px); + min-width: 54px; + padding: 0; + border: 0; + background: transparent; + color: var(--ink); + cursor: pointer; + transform: translate(-50%, -50%); + transition: transform .18s ease, filter .18s ease; +} +.agent:hover { transform: translate(-50%, -53%); filter: brightness(1.04); } +.agent:focus-visible { outline: 3px solid #163d7b; outline-offset: 4px; border-radius: 8px; } +.agent[aria-pressed="true"] { transform: translate(-50%, -54%) scale(1.08); z-index: 5; } +.agent[data-state="planning"] { --accent: #7766a9; } +.agent[data-state="researching"] { --accent: #5b74c7; } +.agent[data-state="executing"], .agent[data-state="waiting_approval"] { --accent: #bf7b22; } +.agent[data-state="completed"] { --accent: #2f8d61; } +.agent[data-state="blocked"] { --accent: #bd4f54; } +.agent[data-state="stale"] { --accent: #788196; } + +.avatar { position: relative; display: block; width: clamp(25px, 3.6vw, 40px); height: clamp(34px, 4.9vw, 53px); filter: drop-shadow(2px 3px 0 rgba(42, 48, 64, .18)); } +.face { position: absolute; top: 11%; left: 21%; display: grid; place-items: center; width: 58%; height: 45%; overflow: hidden; border: 2px solid #4a3e39; border-radius: 46% 46% 43% 43%; background: #efc29f; color: #4a3e39; font-size: clamp(5px, .7vw, 8px); font-weight: 900; line-height: 1; } +.hair { position: absolute; z-index: 1; top: 4%; left: 18%; width: 64%; height: 28%; border-radius: 60% 50% 20% 16%; background: #4e4852; } +.body { position: absolute; bottom: 0; left: 11%; width: 78%; height: 47%; border: 2px solid #314861; border-radius: 45% 45% 10% 10%; background: var(--accent); } +.agent[data-avatar="terra"] .hair { background: #5a3d2d; border-radius: 65% 42% 22% 18%; } +.agent[data-avatar="terra"] .body { background: #416b9d; } +.agent[data-avatar="luna"] .hair { background: #443f68; border-radius: 35% 68% 18% 30%; } +.agent[data-avatar="luna"] .body { background: #8b639f; } +.agent[data-avatar="generated"] .body { background: hsl(var(--avatar-hue) 43% 48%); } +.agent[data-state="researching"] .avatar { animation: office-think 1.3s ease-in-out infinite; } +.agent[data-state="executing"] .body { background: repeating-linear-gradient(45deg, var(--accent), var(--accent) 3px, #ffd082 3px 6px); } +.agent[data-state="blocked"] .face { background: #f0a2a0; } + +.agentName { max-width: 100%; margin-top: 2px; overflow: hidden; color: #273450; font-size: clamp(8px, .93vw, 12px); font-weight: 800; line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; } +.stateBadge { max-width: 100%; margin-top: 2px; padding: 2px 4px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--accent) 42%, white); border-radius: 999px; background: color-mix(in srgb, var(--accent) 14%, white); color: color-mix(in srgb, var(--accent) 85%, #18213b); font-size: clamp(6px, .66vw, 9px); font-weight: 700; line-height: 1; text-overflow: ellipsis; white-space: nowrap; } + +.detail { position: absolute; z-index: 8; right: 14px; bottom: 14px; width: min(280px, calc(100% - 28px)); padding: 10px 12px; border: 1px solid rgba(70, 80, 104, .28); border-radius: 10px; background: rgba(255, 255, 255, .93); box-shadow: 0 8px 24px rgba(27, 39, 59, .18); font-size: 12px; } +.detailTitle { display: flex; align-items: center; gap: 7px; } +.detailTitle strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.detailDot { width: 8px; height: 8px; border-radius: 50%; background: #3c72b7; } +.detailDot[data-state="completed"] { background: #2f8d61; }.detailDot[data-state="blocked"] { background: #bd4f54; }.detailDot[data-state="waiting_approval"], .detailDot[data-state="executing"] { background: #bf7b22; } +.dismiss { margin-left: auto; border: 0; border-radius: 4px; background: transparent; color: #536179; cursor: pointer; font-size: 19px; line-height: 1; } +.dismiss:focus-visible { outline: 2px solid #163d7b; } +.zoneLabel, .modelName, .agentId { display: block; margin-top: 5px; color: var(--muted); font-size: 11px; } +.modelName, .agentId { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.modelName { color: #485b7a; font-family: ui-monospace, monospace; } +.agentId { color: #6d778a; font-size: 10px; } + +.mobileList { display: none; } +.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + +@keyframes office-dash { to { stroke-dashoffset: -20; } } +@keyframes office-think { 50% { transform: translateY(-3px) rotate(-1deg); } } + +@media (max-width: 760px) { + .office { min-height: 0; overflow: visible; background: transparent; border: 0; border-radius: 0; } + .stage { display: none; } + .mobileList { display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: 9px; padding: 12px; border: 1px solid rgba(67, 75, 96, .22); border-radius: 12px; background: #e6edf2; } + .mobileList .agent { position: relative; left: auto; top: auto; grid-template-columns: 36px minmax(0, 1fr); grid-template-rows: auto auto; justify-items: start; width: 100%; min-width: 0; padding: 7px; border: 1px solid rgba(68, 85, 110, .2); border-radius: 8px; background: rgba(255, 255, 255, .7); transform: none; text-align: left; } + .mobileList .agent:hover, .mobileList .agent[aria-pressed="true"] { transform: translateY(-1px); } + .mobileList .avatar { grid-row: 1 / span 2; width: 30px; height: 40px; } + .mobileList .agentName { align-self: end; font-size: 12px; } + .mobileList .stateBadge { align-self: start; margin-top: 1px; } + .detail { position: relative; right: auto; bottom: auto; width: auto; margin-top: 9px; } +} + +@media (prefers-reduced-motion: reduce) { + .edge, .agent, .agent[data-state="researching"] .avatar { animation: none; transition: none; } +} diff --git a/web/hooks/simulation/handle-agent-events.test.ts b/web/hooks/simulation/handle-agent-events.test.ts new file mode 100644 index 00000000..901cab69 --- /dev/null +++ b/web/hooks/simulation/handle-agent-events.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + handleAgentComplete, + handleAgentIdle, + handleAgentSpawn, + handleModelDetected, + handlePermissionRequested, +} from './handle-agent-events' +import { handleSubagentDispatch, handleSubagentReturn } from './handle-subagent-events' +import { createEmptyState } from './types' +import type { ProcessEventContext } from './process-event' + +const context = (): ProcessEventContext => ({ + syncForceSimulation: () => {}, + findToolSlot: () => ({ x: 0, y: 0 }), + getContextWindowSize: () => 100, + blockIdCounter: { current: 0 }, + skipForceSync: true, +}) + +test('uses stable Codex ids for duplicate display names and lifecycle events', () => { + const state = createEmptyState() + const ctx = context() + + handleAgentSpawn({ id: 'root-1', name: 'Orchestrator', isMain: true }, 0, state, ctx) + handleAgentSpawn({ id: 'worker-1', name: 'worker', parentId: 'root-1' }, 1, state, ctx) + handleAgentSpawn({ agentId: 'worker-2', name: 'worker', parentId: 'root-1' }, 2, state, ctx) + // A repeated spawn for the same id reactivates that node; it must not merge + // with the other node that happens to have the same display name. + handleAgentSpawn({ id: 'worker-1', name: 'worker', parentId: 'root-1' }, 3, state, ctx) + + assert.equal(state.agents.size, 3) + assert.equal(state.agents.get('worker-1')?.name, 'worker') + assert.equal(state.agents.get('worker-2')?.name, 'worker') + assert.equal(state.agents.get('worker-1')?.parentId, 'root-1') + assert.equal(state.agents.get('worker-2')?.parentId, 'root-1') + assert.deepEqual( + state.edges.map(edge => edge.id), + ['edge-root-1-worker-1', 'edge-root-1-worker-2'], + ) + + handleSubagentDispatch({ + parentId: 'root-1', childId: 'worker-2', parent: 'worker', child: 'worker', task: 'inspect', + }, 4, state) + handleSubagentReturn({ + parentId: 'root-1', childId: 'worker-2', parent: 'worker', child: 'worker', summary: 'done', + }, 5, state) + assert.equal(state.particles[0]?.edgeId, 'edge-root-1-worker-2') + assert.equal(state.particles[1]?.edgeId, 'edge-root-1-worker-2') + + handleModelDetected({ agentId: 'worker-2', agent: 'worker', model: 'gpt-5.6-terra' }, state, ctx) + assert.equal(state.agents.get('worker-2')?.model, 'gpt-5.6-terra') + assert.equal(state.agents.get('worker-1')?.model, undefined) + + handlePermissionRequested({ agentId: 'worker-2', agent: 'worker' }, 6, state, ctx) + assert.equal(state.agents.get('worker-2')?.state, 'waiting_permission') + handleAgentIdle({ agentId: 'worker-2', name: 'worker' }, state) + assert.equal(state.agents.get('worker-2')?.state, 'thinking') + + handleAgentComplete({ id: 'worker-2', name: 'worker' }, 7, state, ctx) + assert.equal(state.agents.get('worker-2')?.state, 'complete') + assert.notEqual(state.agents.get('worker-1')?.state, 'complete') +}) + +test('retains name-based Claude relation fallback', () => { + const state = createEmptyState() + const ctx = context() + handleAgentSpawn({ name: 'orchestrator', isMain: true }, 0, state, ctx) + handleAgentSpawn({ name: 'worker', parent: 'orchestrator' }, 1, state, ctx) + + handleSubagentDispatch({ parent: 'orchestrator', child: 'worker', task: 'inspect' }, 2, state) + assert.equal(state.particles[0]?.edgeId, 'edge-orchestrator-worker') + handleAgentComplete({ name: 'worker' }, 3, state, ctx) + assert.equal(state.agents.get('worker')?.state, 'complete') +}) diff --git a/web/hooks/simulation/handle-agent-events.ts b/web/hooks/simulation/handle-agent-events.ts index cd7f562e..3d44314f 100644 --- a/web/hooks/simulation/handle-agent-events.ts +++ b/web/hooks/simulation/handle-agent-events.ts @@ -8,14 +8,47 @@ import { AGENT_SPAWN_DISTANCE } from '@/lib/canvas-constants' import { pushTimelineBlock, type ProcessEventContext, type MutableEventState } from './process-event' import { edgeId, asString, asBoolean } from './types' +/** Return the first non-empty string in a list of payload fields. */ +function firstString(payload: Record, fields: string[]): string | undefined { + for (const field of fields) { + const value = payload[field] + if (typeof value === 'string' && value.length > 0) return value + } + return undefined +} + +/** + * Resolve an event's internal agent key. Codex events carry a stable id, + * whereas the legacy Claude protocol identifies agents by their display name. + * An explicit id always wins; name lookup is only the compatibility path. + */ +function resolveAgentId(payload: Record, state: MutableEventState): string { + const explicitId = firstString(payload, ['id', 'agentId']) + if (explicitId) return explicitId + + const legacyReference = firstString(payload, ['agent', 'name']) + if (!legacyReference) return '' + if (state.agents.has(legacyReference)) return legacyReference + + // A legacy-shaped event can still arrive after a stable-id spawn. Resolve + // its display name only when there is no explicit id to disambiguate it. + for (const [id, agent] of state.agents) { + if (agent.name === legacyReference) return id + } + return legacyReference +} + export function handleAgentSpawn( payload: Record, currentTime: number, state: MutableEventState, ctx: ProcessEventContext, ): void { - const name = asString(payload.name) - const parentId = typeof payload.parent === 'string' ? payload.parent : undefined + const displayName = firstString(payload, ['name']) + const agentId = firstString(payload, ['id', 'agentId']) || displayName + if (!agentId) return + const name = displayName || agentId + const parentId = firstString(payload, ['parentId', 'parent']) const isMain = asBoolean(payload.isMain) const task = typeof payload.task === 'string' ? payload.task : undefined const model = typeof payload.model === 'string' ? payload.model : undefined @@ -23,10 +56,12 @@ export function handleAgentSpawn( // If the agent already exists (e.g. session resuming after inactivity), // reactivate it instead of replacing — preserves accumulated stats. - const existing = state.agents.get(name) + const existing = state.agents.get(agentId) if (existing) { - state.agents.set(name, { + state.agents.set(agentId, { ...existing, + ...(displayName ? { name: displayName } : {}), + ...(parentId !== undefined ? { parentId } : {}), state: 'idle', ...(task ? { task } : {}), ...(model ? { model, tokensMax: ctx.getContextWindowSize(model) } : {}), @@ -42,7 +77,7 @@ export function handleAgentSpawn( // Collect angles of existing siblings so we can avoid spawning too close const siblingAngles: number[] = [] for (const a of state.agents.values()) { - if (a.parentId === parentId && a.id !== name) { + if (a.parentId === parentId && a.id !== agentId) { siblingAngles.push(Math.atan2(a.y - parent.y, a.x - parent.x)) } } @@ -50,7 +85,7 @@ export function handleAgentSpawn( let angle: number if (siblingAngles.length === 0) { // First child: use hash-based angle - const hash = name.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0) + const hash = agentId.split('').reduce((h, c) => ((h << 5) - h) + c.charCodeAt(0), 0) angle = (Math.abs(hash) % 360) * (Math.PI / 180) } else { // Find the largest angular gap between existing siblings and place in the middle @@ -74,7 +109,7 @@ export function handleAgentSpawn( } const agent: Agent = { - id: name, name, state: 'idle', + id: agentId, name, state: 'idle', parentId: parentId || null, tokensUsed: 0, tokensMax: ctx.getContextWindowSize(model), contextBreakdown: emptyContextBreakdown(), @@ -88,23 +123,23 @@ export function handleAgentSpawn( opacity: 0, scale: 0.3, messageBubbles: [], } - state.agents.set(name, agent) + state.agents.set(agentId, agent) if (parentId) { - state.edges.push({ id: edgeId(parentId, name), from: parentId, to: name, type: 'parent-child', opacity: 0 }) + state.edges.push({ id: edgeId(parentId, agentId), from: parentId, to: agentId, type: 'parent-child', opacity: 0 }) } const timelineEntry: TimelineEntry = { - id: `timeline-${name}`, - agentId: name, + id: `timeline-${agentId}`, + agentId, agentName: name, startTime: currentTime, blocks: [], } pushTimelineBlock(timelineEntry, currentTime, { type: 'idle', label: 'Starting', color: COLORS.idle }, ctx) - state.timelineEntries.set(name, timelineEntry) + state.timelineEntries.set(agentId, timelineEntry) - state.conversations.set(name, []) + state.conversations.set(agentId, []) if (!ctx.skipForceSync) { setTimeout(() => ctx.syncForceSimulation(state.agents, state.edges), 0) @@ -117,20 +152,20 @@ export function handleAgentComplete( state: MutableEventState, ctx: ProcessEventContext, ): void { - const name = asString(payload.name) - const agent = state.agents.get(name) + const agentId = resolveAgentId(payload, state) + const agent = state.agents.get(agentId) if (agent && agent.state !== 'complete') { - state.agents.set(name, { ...agent, state: 'complete', completeTime: currentTime }) + state.agents.set(agentId, { ...agent, state: 'complete', completeTime: currentTime }) - const entry = state.timelineEntries.get(name) + const entry = state.timelineEntries.get(agentId) if (entry) { pushTimelineBlock(entry, currentTime, { type: 'complete', label: 'Done', color: COLORS.complete, endTime: currentTime }, ctx) entry.endTime = currentTime } - const agentsToComplete = [name] + const agentsToComplete = [agentId] for (const [childId, childAgent] of state.agents) { - if (childAgent.parentId === name && childAgent.state !== 'complete') { + if (childAgent.parentId === agentId && childAgent.state !== 'complete') { state.agents.set(childId, { ...childAgent, state: 'complete', completeTime: currentTime }) agentsToComplete.push(childId) const childEntry = state.timelineEntries.get(childId) @@ -155,15 +190,15 @@ export function handlePermissionRequested( state: MutableEventState, ctx: ProcessEventContext, ): void { - const agentName = asString(payload.agent, 'Orchestrator') - const agent = state.agents.get(agentName) + const agentId = resolveAgentId(payload, state) || 'Orchestrator' + const agent = state.agents.get(agentId) if (agent && agent.state !== 'complete') { - state.agents.set(agentName, { + state.agents.set(agentId, { ...agent, state: 'waiting_permission', }) - const entry = state.timelineEntries.get(agentName) + const entry = state.timelineEntries.get(agentId) if (entry) { pushTimelineBlock(entry, currentTime, { type: 'idle', label: 'Permission', color: COLORS.waiting_permission }, ctx) } @@ -174,10 +209,10 @@ export function handleAgentIdle( payload: Record, state: MutableEventState, ): void { - const idleName = asString(payload.name) - const idleAgent = state.agents.get(idleName) + const idleId = resolveAgentId(payload, state) + const idleAgent = state.agents.get(idleId) if (idleAgent && (idleAgent.state === 'tool_calling' || idleAgent.state === 'waiting_permission')) { - state.agents.set(idleName, { ...idleAgent, state: 'thinking', currentTool: undefined }) + state.agents.set(idleId, { ...idleAgent, state: 'thinking', currentTool: undefined }) } } @@ -186,11 +221,11 @@ export function handleModelDetected( state: MutableEventState, ctx: ProcessEventContext, ): void { - const agentName = asString(payload.agent) + const agentId = resolveAgentId(payload, state) const model = asString(payload.model) - const agent = state.agents.get(agentName) + const agent = state.agents.get(agentId) if (agent) { - state.agents.set(agentName, { + state.agents.set(agentId, { ...agent, model, tokensMax: ctx.getContextWindowSize(model), diff --git a/web/hooks/simulation/handle-subagent-events.ts b/web/hooks/simulation/handle-subagent-events.ts index 73616ac7..90ee528d 100644 --- a/web/hooks/simulation/handle-subagent-events.ts +++ b/web/hooks/simulation/handle-subagent-events.ts @@ -2,14 +2,21 @@ import { COLORS } from '@/lib/colors' import type { MutableEventState } from './process-event' import { edgeId, asString, LABEL_LEN_SHORT } from './types' +/** Codex supplies stable relation ids; Claude events only have display names. */ +function relationId(payload: Record, idField: string, nameField: string): string { + return typeof payload[idField] === 'string' && payload[idField] + ? payload[idField] as string + : asString(payload[nameField]) +} + export function handleSubagentDispatch( payload: Record, currentTime: number, state: MutableEventState, ): void { - const parentName = asString(payload.parent) - const childName = asString(payload.child) - const eid = edgeId(parentName, childName) + const parentId = relationId(payload, 'parentId', 'parent') + const childId = relationId(payload, 'childId', 'child') + const eid = edgeId(parentId, childId) const task = asString(payload.task) state.particles.push({ @@ -26,9 +33,9 @@ export function handleSubagentReturn( currentTime: number, state: MutableEventState, ): void { - const parentName = asString(payload.parent) - const childName = asString(payload.child) - const eid = edgeId(parentName, childName) + const parentId = relationId(payload, 'parentId', 'parent') + const childId = relationId(payload, 'childId', 'child') + const eid = edgeId(parentId, childId) const summary = asString(payload.summary) state.particles.push({ diff --git a/web/lib/office/index.ts b/web/lib/office/index.ts new file mode 100644 index 00000000..8266a260 --- /dev/null +++ b/web/lib/office/index.ts @@ -0,0 +1,12 @@ +export { officeAgentId, projectOffice, projectOfficeGraph } from './project-office' +export type { + OfficeAgent, + OfficeAgentState, + OfficeAvatar, + OfficeEdge, + OfficeEvent, + OfficeGraphSource, + OfficeProjection, + OfficeProjectionOptions, + OfficeZone, +} from './types' diff --git a/web/lib/office/project-office.test.ts b/web/lib/office/project-office.test.ts new file mode 100644 index 00000000..379c2a6e --- /dev/null +++ b/web/lib/office/project-office.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { officeAgentId, projectOffice, projectOfficeGraph } from './project-office' +import type { Agent, Edge } from '../agent-types' +import type { OfficeEvent } from './types' + +const event = (type: string, payload: Record, time = 1, sessionId = 'session-a'): OfficeEvent => ({ + time, + type, + payload, + sessionId, +}) + +test('deduplicates duplicate spawns and keeps stable session-scoped IDs', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'worker', model: 'gpt-5.6-terra' }), + event('agent_spawn', { name: 'worker', model: 'gpt-5.6-terra' }), + ]) + + assert.equal(projection.agents.size, 1) + assert.ok(projection.agents.has(officeAgentId('session-a', 'worker'))) +}) + +test('registers an unknown agent immediately after the initial projection and remains idempotent', () => { + const sessionId = 'office-contract-session' + const initial = projectOffice([], { now: 100 }) + assert.equal(initial.agents.size, 0) + + const spawn = event( + 'agent_spawn', + { name: 'future-agent', model: 'vendor-future-v1' }, + 101, + sessionId, + ) + const startedAt = performance.now() + const first = projectOffice([spawn]) + const elapsedMs = performance.now() - startedAt + const agentId = officeAgentId(sessionId, 'future-agent') + + assert.ok(elapsedMs < 2_000, `projection took ${elapsedMs.toFixed(1)}ms`) + assert.equal(first.agents.size, 1) + assert.equal(first.agents.get(agentId)?.model, 'vendor-future-v1') + assert.equal(first.agents.get(agentId)?.avatar.family, 'generated') + + const replay = projectOffice([spawn, { ...spawn, time: 102 }]) + assert.equal(replay.agents.size, 1) + assert.deepEqual(replay.agents.get(agentId), first.agents.get(agentId)) +}) + +test('same agent name in separate sessions has separate identities', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'worker' }, 1, 'session-a'), + event('agent_spawn', { name: 'worker' }, 1, 'session-b'), + ]) + + assert.equal(projection.agents.size, 2) + assert.notEqual(officeAgentId('session-a', 'worker'), officeAgentId('session-b', 'worker')) +}) + +test('does not claim a parent until the parent itself is observed', () => { + const childId = officeAgentId('session-a', 'child') + const parentId = officeAgentId('session-a', 'parent') + const beforeParent = projectOffice([event('agent_spawn', { name: 'child', parent: 'parent' })]) + assert.equal(beforeParent.agents.get(childId)?.parentId, null) + assert.equal(beforeParent.edges.length, 0) + + const afterParent = projectOffice([ + event('agent_spawn', { name: 'child', parent: 'parent' }), + event('agent_spawn', { name: 'parent' }, 2), + ]) + assert.equal(afterParent.agents.get(childId)?.parentId, parentId) + assert.equal(afterParent.edges[0]?.evidence, 'agent_spawn') +}) + +test('keeps Terra and Luna explicit while accepting model IDs outside a catalogue', () => { + const projection = projectOffice([ + event('agent_spawn', { name: 'terra', model: 'gpt-5.6-terra' }), + event('agent_spawn', { name: 'luna', model: 'gpt-5.6-luna' }), + event('agent_spawn', { name: 'future', model: 'acme-next-42' }), + ]) + + assert.equal(projection.agents.get(officeAgentId('session-a', 'terra'))?.avatar.family, 'terra') + assert.equal(projection.agents.get(officeAgentId('session-a', 'luna'))?.avatar.family, 'luna') + assert.deepEqual(projection.agents.get(officeAgentId('session-a', 'future'))?.avatar.family, 'generated') + assert.equal(projection.agents.get(officeAgentId('session-a', 'future'))?.model, 'acme-next-42') +}) + +test('ignores an unknown event type rather than inventing an agent or state', () => { + const projection = projectOffice([event('relay_future_event', { agent: 'not-created' })]) + assert.equal(projection.agents.size, 0) +}) + +test('hydrates a previously unknown agent without regressing a newer state', () => { + const workerId = officeAgentId('session-a', 'worker') + const projection = projectOffice([ + event('tool_call_start', { agent: 'worker', tool: 'apply_patch' }, 10), + event('agent_spawn', { name: 'worker', model: 'gpt-5.6-terra' }, 2), + ]) + const worker = projection.agents.get(workerId) + + assert.equal(worker?.state, 'editing') + assert.equal(worker?.model, 'gpt-5.6-terra') + assert.equal(worker?.avatar.family, 'terra') +}) + +test('adapts graph agents and only joins a parent when a parent-child edge exists', () => { + const parent: Agent = { + id: 'parent', name: 'Parent', state: 'idle', parentId: null, + tokensUsed: 0, tokensMax: 0, contextBreakdown: { systemPrompt: 0, userMessages: 0, toolResults: 0, reasoning: 0, subagentResults: 0 }, + toolCalls: 0, timeAlive: 0, x: 0, y: 0, vx: 0, vy: 0, pinned: false, isMain: true, + spawnTime: 1, opacity: 1, scale: 1, messageBubbles: [], + } + const child: Agent = { ...parent, id: 'child', name: 'Child', parentId: 'parent', isMain: false } + const edge: Edge = { id: 'parent-child', from: 'parent', to: 'child', type: 'parent-child', opacity: 1 } + + const withoutEdge = projectOfficeGraph({ sessionId: 'graph-session', agents: [parent, child], edges: [] }) + assert.equal(withoutEdge.agents.get(officeAgentId('graph-session', 'child'))?.parentId, null) + + const withEdge = projectOfficeGraph({ sessionId: 'graph-session', agents: [parent, child], edges: [edge] }) + assert.equal(withEdge.agents.get(officeAgentId('graph-session', 'child'))?.parentId, officeAgentId('graph-session', 'parent')) +}) diff --git a/web/lib/office/project-office.ts b/web/lib/office/project-office.ts new file mode 100644 index 00000000..631dfedc --- /dev/null +++ b/web/lib/office/project-office.ts @@ -0,0 +1,384 @@ +import type { Agent, Edge, SimulationEvent } from '../agent-types' +import type { + OfficeAgent, + OfficeAgentState, + OfficeAvatar, + OfficeEdge, + OfficeEvent, + OfficeGraphSource, + OfficeProjection, + OfficeProjectionOptions, +} from './types' + +const DEFAULT_SESSION_ID = 'default-session' +const DEFAULT_STALE_AFTER_SECONDS = 120 +const SLOT_COUNT = 12 + +interface MutableOfficeState { + agents: Map + /** Child -> parent evidence. An edge is materialized only after both exist. */ + parentEvidence: Map +} + +/** FNV-1a produces deterministic, browser-safe opaque identifiers without a dependency. */ +function hash(value: string, seed = 0x811c9dc5): string { + let result = seed >>> 0 + for (let index = 0; index < value.length; index++) { + result ^= value.charCodeAt(index) + result = Math.imul(result, 0x01000193) >>> 0 + } + return (`00000000${result.toString(16)}`).slice(-8) +} + +function sourceKey(sessionId: string, agentId: string): string { + return `${sessionId.length}:${sessionId}\u0000${agentId.length}:${agentId}` +} + +/** Stable across render/replay, and distinct between different sessions in practice. */ +export function officeAgentId(sessionId: string | undefined, agentId: string): string { + const key = sourceKey(sessionId || DEFAULT_SESSION_ID, agentId) + return `office-${hash(key)}-${hash(key, 0x9e3779b9)}` +} + +function text(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed || undefined +} + +function eventTime(event: OfficeEvent): number { + return Number.isFinite(event.time) ? event.time : 0 +} + +function displayName(agentId: string): string { + // Do not retain task/message content. Agent names are bounded to avoid making + // an accidental payload-sized identifier part of the projection. + return agentId.replace(/\s+/g, ' ').slice(0, 96) +} + +function avatarFor(agentId: string, model?: string): OfficeAvatar { + const normalized = model?.toLowerCase() + if (normalized?.includes('terra')) return { family: 'terra', key: 'terra' } + if (normalized?.includes('luna')) return { family: 'luna', key: 'luna' } + + // Unknown and future model IDs are preserved in `model`; their artwork is + // deterministic rather than being rejected or silently relabelled. + return { family: 'generated', key: `generated-${hash(model || agentId)}` } +} + +function slotFor(sessionId: string, agentId: string): number { + return parseInt(hash(sourceKey(sessionId, agentId)).slice(0, 6), 16) % SLOT_COUNT +} + +function ensureAgent(state: MutableOfficeState, sessionId: string, sourceAgentId: string, at: number): OfficeAgent { + const id = officeAgentId(sessionId, sourceAgentId) + const existing = state.agents.get(id) + if (existing) return existing + + const agent: OfficeAgent = { + id, + name: displayName(sourceAgentId), + parentId: null, + state: 'unknown', + zone: 'unknown', + avatar: avatarFor(sourceAgentId), + slot: slotFor(sessionId, sourceAgentId), + lastActivityAt: at, + } + state.agents.set(id, agent) + resolveKnownParent(state, id) + resolveChildrenForParent(state, id) + return agent +} + +function replaceAgent(state: MutableOfficeState, agent: OfficeAgent, changes: Partial): OfficeAgent { + const next = { ...agent, ...changes } + state.agents.set(agent.id, next) + return next +} + +function resolveKnownParent(state: MutableOfficeState, childId: string): void { + const relation = state.parentEvidence.get(childId) + const child = state.agents.get(childId) + if (!relation || !child || !state.agents.has(relation.parentId)) return + if (child.parentId !== relation.parentId) replaceAgent(state, child, { parentId: relation.parentId }) +} + +function resolveChildrenForParent(state: MutableOfficeState, parentId: string): void { + for (const [childId, relation] of state.parentEvidence) { + if (relation.parentId === parentId) resolveKnownParent(state, childId) + } +} + +function recordParentEvidence( + state: MutableOfficeState, + parentId: string, + childId: string, + evidence: OfficeEdge['evidence'], +): void { + // This is the only route to a parentId: same-name guesses are never used. + state.parentEvidence.set(childId, { parentId, evidence }) + resolveKnownParent(state, childId) +} + +function setState(state: MutableOfficeState, agent: OfficeAgent, nextState: OfficeAgentState, at: number): void { + // A delayed event can enrich metadata, but it must not roll the visible + // state backwards over a more recent observation. + if (at < agent.lastActivityAt) return + replaceAgent(state, agent, { state: nextState, zone: nextState, lastActivityAt: at }) +} + +function stateForTool(tool: string): OfficeAgentState { + const normalized = tool.toLowerCase() + if (/(todo|plan)/.test(normalized)) return 'planning' + if (/(search|fetch|read|grep|glob|find|research|browse)/.test(normalized)) return 'researching' + if (/(edit|write|patch|replace)/.test(normalized)) return 'editing' + // A tool invocation is direct evidence of work, but no claim is made about + // its domain when the tool is unfamiliar. + return 'executing' +} + +function stateForThinking(content: unknown): OfficeAgentState { + const normalized = text(content)?.toLowerCase() + if (!normalized) return 'unknown' + if (/(blocked|failed|failure|error)/.test(normalized)) return 'blocked' + if (/(approval|permission|approve)/.test(normalized)) return 'waiting_approval' + if (/(planning|plan |planing)/.test(normalized)) return 'planning' + if (/(research|searching|investigat)/.test(normalized)) return 'researching' + if (/(editing|writing|implementing|patching)/.test(normalized)) return 'editing' + if (/(executing|running|testing|deploying)/.test(normalized)) return 'executing' + return 'unknown' +} + +function actor(payload: Record, field: 'agent' | 'name' | 'parent' | 'child'): string | undefined { + return text(payload[field]) +} + +function eventAgent( + state: MutableOfficeState, + sessionId: string, + agentId: string | undefined, + at: number, +): OfficeAgent | undefined { + return agentId ? ensureAgent(state, sessionId, agentId, at) : undefined +} + +function applyEvent(state: MutableOfficeState, event: OfficeEvent): void { + const sessionId = event.sessionId || DEFAULT_SESSION_ID + const at = eventTime(event) + const payload = event.payload || {} + + switch (event.type) { + case 'agent_spawn': { + const sourceAgentId = actor(payload, 'name') + const agent = eventAgent(state, sessionId, sourceAgentId, at) + if (!agent || !sourceAgentId) return + + const model = text(payload.model) + const changes: Partial = {} + if (model) changes.model = model + if (model) changes.avatar = avatarFor(sourceAgentId, model) + if (Object.keys(changes).length) replaceAgent(state, agent, changes) + const parentSourceId = actor(payload, 'parent') + if (parentSourceId) { + recordParentEvidence(state, officeAgentId(sessionId, parentSourceId), agent.id, 'agent_spawn') + } + return + } + + case 'agent_complete': { + const agent = eventAgent(state, sessionId, actor(payload, 'name'), at) + if (agent) setState(state, agent, 'completed', at) + return + } + + case 'agent_idle': { + const agent = eventAgent(state, sessionId, actor(payload, 'name'), at) + if (agent) setState(state, agent, 'idle', at) + return + } + + case 'permission_requested': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent') || actor(payload, 'name'), at) + if (agent) setState(state, agent, 'waiting_approval', at) + return + } + + case 'tool_call_start': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + const tool = text(payload.tool) + if (agent) setState(state, agent, tool ? stateForTool(tool) : 'executing', at) + return + } + + case 'tool_call_end': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + if (!agent) return + // Error status is explicit. A successful tool result alone does not tell + // us what the agent is doing next, so it returns to unknown. + setState(state, agent, payload.isError === true ? 'blocked' : 'unknown', at) + return + } + + case 'message': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + if (!agent) return + const next = payload.role === 'thinking' ? stateForThinking(payload.content) : 'unknown' + setState(state, agent, next, at) + return + } + + case 'context_update': { + const agent = eventAgent(state, sessionId, actor(payload, 'agent'), at) + if (agent) setState(state, agent, 'unknown', at) + return + } + + case 'model_detected': { + const sourceAgentId = actor(payload, 'agent') || actor(payload, 'name') + const agent = eventAgent(state, sessionId, sourceAgentId, at) + const model = text(payload.model) + if (agent && model && sourceAgentId) { + replaceAgent(state, agent, { model, avatar: avatarFor(sourceAgentId, model) }) + } + return + } + + case 'subagent_dispatch': { + const parent = eventAgent(state, sessionId, actor(payload, 'parent'), at) + const child = eventAgent(state, sessionId, actor(payload, 'child'), at) + if (parent) setState(state, parent, 'planning', at) + if (parent && child) recordParentEvidence(state, parent.id, child.id, 'subagent_dispatch') + return + } + + case 'subagent_return': { + const parent = eventAgent(state, sessionId, actor(payload, 'parent'), at) + const child = eventAgent(state, sessionId, actor(payload, 'child'), at) + if (parent) setState(state, parent, 'unknown', at) + if (child) setState(state, child, 'unknown', at) + return + } + + default: + // Forward-compatible and deliberately no-op. Unknown relay events are + // not evidence for a particular person, zone, or model. + } +} + +function edgesFor(state: MutableOfficeState): OfficeEdge[] { + const edges: OfficeEdge[] = [] + for (const [childId, relation] of state.parentEvidence) { + const child = state.agents.get(childId) + if (!child || child.parentId !== relation.parentId || !state.agents.has(relation.parentId)) continue + edges.push({ + id: `office-edge-${relation.parentId}-${childId}`, + parentId: relation.parentId, + childId, + evidence: relation.evidence, + }) + } + return edges.sort((a, b) => a.id.localeCompare(b.id)) +} + +function withStaleState(agent: OfficeAgent, now: number, staleAfterSeconds: number): OfficeAgent { + if (!Number.isFinite(staleAfterSeconds) || staleAfterSeconds < 0) return agent + if (agent.state === 'completed' || agent.state === 'blocked' || agent.state === 'waiting_approval') return agent + if (now - agent.lastActivityAt <= staleAfterSeconds) return agent + return { ...agent, state: 'stale', zone: 'stale' } +} + +function stateFromGraphAgent(agent: Agent): OfficeAgentState { + switch (agent.state) { + case 'tool_calling': return 'executing' + case 'complete': return 'completed' + case 'error': return 'blocked' + case 'waiting_permission': return 'waiting_approval' + case 'idle': return 'idle' + // The graph's "thinking" and "paused" labels do not provide enough + // evidence to choose an Office work zone. + case 'thinking': + case 'paused': return 'unknown' + } +} + +function applyGraphAgent(state: MutableOfficeState, sessionId: string, source: Agent): void { + const at = Number.isFinite(source.completeTime) ? source.completeTime! + : Number.isFinite(source.spawnTime) ? source.spawnTime + : 0 + const agent = ensureAgent(state, sessionId, source.id, at) + const changes: Partial = { + name: displayName(source.name), + state: stateFromGraphAgent(source), + zone: stateFromGraphAgent(source), + lastActivityAt: at, + } + if (source.model) { + changes.model = source.model + changes.avatar = avatarFor(source.id, source.model) + } + replaceAgent(state, agent, changes) +} + +function applyGraphEdge(state: MutableOfficeState, sessionId: string, edge: Edge): void { + if (edge.type !== 'parent-child') return + const parentId = officeAgentId(sessionId, edge.from) + const childId = officeAgentId(sessionId, edge.to) + // Graph edges are direct parent-child evidence, but never cause a phantom + // parent/child record to be invented. + if (state.agents.has(parentId) && state.agents.has(childId)) { + recordParentEvidence(state, parentId, childId, 'agent_spawn') + } +} + +function projectionFromState(state: MutableOfficeState, options: OfficeProjectionOptions, newestEventTime: number): OfficeProjection { + const now = options.now ?? newestEventTime + const staleAfterSeconds = options.staleAfterSeconds ?? DEFAULT_STALE_AFTER_SECONDS + const agents = new Map() + for (const [id, agent] of state.agents) agents.set(id, withStaleState(agent, now, staleAfterSeconds)) + return { agents, edges: edgesFor(state) } +} + +/** + * Derive an Office projection from existing Agent/Edge/SimulationEvent inputs. + * The function is pure and does not retain source payloads, making it suitable + * for replay, live updates, and tests alike. + */ +export function projectOffice( + events: readonly (SimulationEvent | OfficeEvent)[], + options: OfficeProjectionOptions = {}, +): OfficeProjection { + const state: MutableOfficeState = { + agents: new Map(), + parentEvidence: new Map(), + } + + let newestEventTime = 0 + for (const event of events) { + newestEventTime = Math.max(newestEventTime, eventTime(event)) + applyEvent(state, event) + } + + return projectionFromState(state, options, newestEventTime) +} + +/** + * Adapt the already materialized Agent/Edge graph. Graph edges, not matching + * names or source `parentId` fields, are the evidence used to connect people. + */ +export function projectOfficeGraph(source: OfficeGraphSource, options: OfficeProjectionOptions = {}): OfficeProjection { + const sessionId = source.sessionId || DEFAULT_SESSION_ID + const state: MutableOfficeState = { + agents: new Map(), + parentEvidence: new Map(), + } + let newestEventTime = 0 + + for (const agent of source.agents) { + applyGraphAgent(state, sessionId, agent) + newestEventTime = Math.max(newestEventTime, state.agents.get(officeAgentId(sessionId, agent.id))?.lastActivityAt ?? 0) + } + for (const edge of source.edges) applyGraphEdge(state, sessionId, edge) + + return projectionFromState(state, options, newestEventTime) +} diff --git a/web/lib/office/types.ts b/web/lib/office/types.ts new file mode 100644 index 00000000..74af4b19 --- /dev/null +++ b/web/lib/office/types.ts @@ -0,0 +1,81 @@ +/** + * A deliberately small, UI-agnostic view of a running agent. This is not a + * second source of truth: it is a privacy-conscious projection of the event + * stream already understood by the visualizer. + */ +export type OfficeAgentState = + | 'unknown' + | 'planning' + | 'researching' + | 'editing' + | 'executing' + | 'waiting_approval' + | 'blocked' + | 'completed' + | 'idle' + | 'stale' + +/** The office zone intentionally mirrors the evidence-backed agent state. */ +export type OfficeZone = OfficeAgentState + +export interface OfficeAvatar { + /** A stable presentational key; consumers choose the actual artwork. */ + key: string + /** Known families get a friendly key; every other model gets a generated one. */ + family: 'terra' | 'luna' | 'generated' +} + +export interface OfficeAgent { + /** Opaque, deterministic ID scoped to sessionId + source agent ID. */ + id: string + /** A short label only. Task text, messages and tool arguments are excluded. */ + name: string + parentId: string | null + state: OfficeAgentState + zone: OfficeZone + /** The original reported model, without imposing a catalogue of valid models. */ + model?: string + avatar: OfficeAvatar + /** A deterministic placement hint for a UI, not persisted layout state. */ + slot: number + /** Event-clock timestamp in seconds, as supplied by SimulationEvent.time. */ + lastActivityAt: number +} + +export interface OfficeEdge { + id: string + parentId: string + childId: string + evidence: 'agent_spawn' | 'subagent_dispatch' +} + +export interface OfficeProjection { + agents: ReadonlyMap + edges: readonly OfficeEdge[] +} + +/** + * A widening of SimulationEvent used at the boundary: a relay can send a + * future event type before this package has learned about it. Such events are + * safely ignored instead of being coerced into a misleading office state. + */ +export interface OfficeEvent { + time: number + type: string + payload: Record + sessionId?: string +} + +export interface OfficeProjectionOptions { + /** Defaults to the newest event timestamp (so replay output is stable). */ + now?: number + /** In event-clock seconds. Set to Infinity to disable stale decoration. */ + staleAfterSeconds?: number +} + +/** Existing visualizer graph state adapted without exposing UI implementation details. */ +export interface OfficeGraphSource { + sessionId?: string + agents: Iterable + edges: Iterable +} diff --git a/web/package.json b/web/package.json index 7692369a..e55cd757 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "build:webview": "vite build --config vite.config.webview.ts" + "build:webview": "vite build --config vite.config.webview.ts", + "test": "node --import tsx --test \"hooks/**/*.test.ts\" \"lib/**/*.test.ts\"" }, "dependencies": { "d3-force": "^3.0.0", From 8de6f4cea2d99d6f33d6ab5a0e5d09d9c68e870a Mon Sep 17 00:00:00 2001 From: Aday Date: Sun, 6 Sep 2026 02:19:00 +0100 Subject: [PATCH 02/26] feat: label agents by work role --- README.md | 5 ++ docs/OFFICE_MODE.md | 10 ++++ extension/src/codex-rollout-parser.ts | 18 +++++++ extension/test/codex-rollout-parser.test.ts | 1 + .../agent-visualizer/agent-detail-card.tsx | 3 +- .../agent-visualizer/canvas/draw-agents.ts | 2 +- .../agent-visualizer/message-feed-panel.tsx | 6 +-- .../agent-visualizer/office/OfficeView.tsx | 20 ++++--- .../agent-visualizer/office/office.module.css | 2 +- web/hooks/simulation/handle-agent-events.ts | 16 ++++++ web/hooks/simulation/handle-tool-events.ts | 4 ++ web/lib/agent-role.test.ts | 23 ++++++++ web/lib/agent-role.ts | 54 +++++++++++++++++++ web/lib/agent-types.ts | 5 ++ web/lib/office/project-office.test.ts | 12 +++++ web/lib/office/project-office.ts | 31 ++++++++++- web/lib/office/types.ts | 6 +++ 17 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 web/lib/agent-role.test.ts create mode 100644 web/lib/agent-role.ts diff --git a/README.md b/README.md index 3fcfa55b..b458d746 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ different sessions remain distinct and nested agents can be represented. Terra and Luna receive their named avatar families; other and future model IDs are kept as reported without a hard-coded model catalogue. +Agents also receive a short work-based label (for example, `Seguridad · Terra` +or `Validador · Luna`). Labels come from a closed vocabulary inferred from +bounded work hints; prompts and task text are not copied into the UI. The +opaque agent ID remains available for exact identification. + Office intentionally exposes only bounded names, model IDs, states, zones, and relationship evidence. Prompts, transcript text, file paths, and tool arguments are not passed to the Office view. The implementation is local and diff --git a/docs/OFFICE_MODE.md b/docs/OFFICE_MODE.md index 50d9fcc0..2a0f78e8 100644 --- a/docs/OFFICE_MODE.md +++ b/docs/OFFICE_MODE.md @@ -29,6 +29,16 @@ from the source agent ID and session ID. This keeps an identity stable across render/replay while preventing same-named agents in separate sessions from colliding. +## Work-based names + +The UI assigns each agent a short role label from a closed Spanish vocabulary: +Orquestador, Seguridad, Validador, Investigador, Implementador, Documentador, +Diseñador, Integrador, Analista, or Especialista. The role is inferred from +bounded work hints such as a Codex `task_name`, a tool name, or the model family. +The original task/prompt is never copied into the label. The stable opaque ID +remains visible in the detail panel so agents with the same role are still +distinguishable. + Nested agents are connected only by explicit `agent_spawn` or `subagent_dispatch` evidence (or an existing Graph parent-child edge). Matching names alone never create a parent/child relationship, and a parent that has diff --git a/extension/src/codex-rollout-parser.ts b/extension/src/codex-rollout-parser.ts index 2eb69384..973ccd47 100644 --- a/extension/src/codex-rollout-parser.ts +++ b/extension/src/codex-rollout-parser.ts @@ -224,9 +224,25 @@ interface CodexSubagentIdentity { name: string task?: string model?: string + /** Fixed role key inferred from task_name; task text is never emitted. */ + workRole?: string parentId: string } +function codexWorkRole(hint: string | undefined): string { + const value = (hint || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().slice(0, 256) + if (/orchestrat|coordina|supervis|dispatch|delegat|manager/.test(value)) return 'orchestrator' + if (/security|seguridad|secure|vulnerab|auth|permission|secret|threat|audit/.test(value)) return 'security' + if (/test|qa|validat|verif|check|lint|review|regression|quality|prueba/.test(value)) return 'validator' + if (/research|investig|search|browse|explor|discover|documentacion|docs?\b|read|grep|find/.test(value)) return 'researcher' + if (/implement|build|code|develop|edit|write|patch|fix|refactor|feature|program/.test(value)) return 'implementer' + if (/document|readme|guide|manual|changelog/.test(value)) return 'documenter' + if (/design|diseñ|visual|ui|ux|frontend|css|layout/.test(value)) return 'designer' + if (/deploy|release|publish|github|merge|integrat|pr\b|pipeline|ship/.test(value)) return 'integrator' + if (/analys|analiz|inspect|diagnos|metrics|kpi|report/.test(value)) return 'analyst' + return 'specialist' +} + interface TurnContextPayload { model?: string cwd?: string @@ -741,6 +757,7 @@ export class CodexRolloutParser { ...((safeIdentityPart(output?.model) || call.model) ? { model: safeIdentityPart(output?.model) || call.model } : {}), + workRole: codexWorkRole(call.taskName || safeIdentityPart(output?.role)), } } @@ -769,6 +786,7 @@ export class CodexRolloutParser { isMain: false, task: child.task || child.name, ...(child.model ? { model: child.model } : {}), + ...(child.workRole ? { workRole: child.workRole } : {}), runtime: 'codex', }, }) diff --git a/extension/test/codex-rollout-parser.test.ts b/extension/test/codex-rollout-parser.test.ts index f28d3645..c1206d5c 100644 --- a/extension/test/codex-rollout-parser.test.ts +++ b/extension/test/codex-rollout-parser.test.ts @@ -316,6 +316,7 @@ describe('CodexRolloutParser', () => { assert.equal(spawns[0].payload.parentId, 'root-thread') assert.equal(spawns[0].payload.task, 'researcher') assert.equal(spawns[0].payload.model, 'future-codex-model') + assert.equal(spawns[0].payload.workRole, 'researcher') assert.equal(events.filter(e => e.type === 'subagent_dispatch').length, 1) assert.equal(JSON.stringify(events).includes('redacted task prompt'), false) }) diff --git a/web/components/agent-visualizer/agent-detail-card.tsx b/web/components/agent-visualizer/agent-detail-card.tsx index fd2b881a..b5eaf60f 100644 --- a/web/components/agent-visualizer/agent-detail-card.tsx +++ b/web/components/agent-visualizer/agent-detail-card.tsx @@ -10,6 +10,7 @@ interface AgentDetailCardProps { agent: { id: string name: string + workLabel?: string state: AgentState model?: string tokensUsed: number @@ -51,7 +52,7 @@ export function AgentDetailCard({ />
- {agent.name} + {agent.workLabel || agent.name} {agent.model && ( diff --git a/web/components/agent-visualizer/canvas/draw-agents.ts b/web/components/agent-visualizer/canvas/draw-agents.ts index 04c510ff..78f88061 100644 --- a/web/components/agent-visualizer/canvas/draw-agents.ts +++ b/web/components/agent-visualizer/canvas/draw-agents.ts @@ -312,7 +312,7 @@ function drawAgentLabel(ctx: CanvasRenderingContext2D, agent: Agent, r: number, ctx.textAlign = 'center' ctx.textBaseline = 'top' const maxLabelW = r * AGENT_DRAW.labelWidthMultiplier - const agentLabel = truncateText(ctx, agent.name, maxLabelW) + const agentLabel = truncateText(ctx, agent.workLabel || agent.name, maxLabelW) ctx.fillText(agentLabel, agent.x, agent.y + r + AGENT_DRAW.labelYOffset) } diff --git a/web/components/agent-visualizer/message-feed-panel.tsx b/web/components/agent-visualizer/message-feed-panel.tsx index cba4b8ab..aad14f6f 100644 --- a/web/components/agent-visualizer/message-feed-panel.tsx +++ b/web/components/agent-visualizer/message-feed-panel.tsx @@ -185,7 +185,7 @@ export function MessageFeedPanel({ if (!expanded) { if (!latestMessage) return null const agent = agents.get(latestMessage.agentId) - const agentName = agent?.name ?? latestMessage.agentId + const agentName = agent?.workLabel || agent?.name || latestMessage.agentId const role = ROLE_COLORS[latestMessage.type] ?? ROLE_COLORS.assistant const preview = latestMessage.content.replace(/\n/g, ' ').slice(0, PREVIEW_MAX) @@ -243,7 +243,7 @@ export function MessageFeedPanel({ /> {agentsWithMessages.map(agentId => { const agent = agents.get(agentId) - const name = agent?.name ?? agentId + const name = agent?.workLabel || agent?.name || agentId const color = agent ? getStateColor(agent.state) : COLORS.idle return ( { onAgentClick(msg.agentId); setExpanded(false) }} diff --git a/web/components/agent-visualizer/office/OfficeView.tsx b/web/components/agent-visualizer/office/OfficeView.tsx index 4dc7f41e..9e67b01b 100644 --- a/web/components/agent-visualizer/office/OfficeView.tsx +++ b/web/components/agent-visualizer/office/OfficeView.tsx @@ -97,6 +97,10 @@ function initials(name: string): string { return (words.slice(0, 2).map(word => word[0]).join('') || 'A').toUpperCase() } +function visibleName(agent: OfficeAgent): string { + return agent.workLabel || agent.name +} + function avatarHue(key: string): number { let value = 0 for (let index = 0; index < key.length; index += 1) value = (value * 31 + key.charCodeAt(index)) % 360 @@ -170,7 +174,7 @@ export function OfficeView({ return (
- {selected ? `${selected.name}: ${STATE_LABELS[selected.state]}` : `${agents.length} agentes en la oficina`} + {selected ? `${visibleName(selected)}: ${STATE_LABELS[selected.state]}` : `${agents.length} agentes en la oficina`}
@@ -240,14 +244,15 @@ export function OfficeView({
{selected && ( -