From aeaa474154833f239ecb95d832798ce1a3331779 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 3 Jun 2026 12:29:22 -0300 Subject: [PATCH] Add agent debug NDJSON MCP tools and swift-agent-debug-log skill. Bridge Cursor DEBUG MODE with build/run, host log read/clear, simulator container pull, one-shot repro, and optional structured log capture filters. Co-authored-by: Cursor --- .agents/skills/simulator-workflow/SKILL.md | 6 + .agents/skills/swift-agent-debug-log/SKILL.md | 109 ++++++++ skills/swift-agent-debug-log/SKILL.md | 109 ++++++++ src/cli.test.ts | 4 +- src/cli/commands.ts | 66 ++++- src/core/agent-debug-log.test.ts | 94 +++++++ src/core/agent-debug-log.ts | 234 ++++++++++++++++++ src/core/workflows.ts | 13 +- src/mcp/server.test.ts | 5 +- src/mcp/server.ts | 23 ++ src/tools/bazel-tools.test.ts | 26 +- src/tools/bazel-tools.ts | 3 +- src/tools/handlers/agent-debug.ts | 203 +++++++++++++++ src/tools/handlers/handlers.test.ts | 6 +- src/tools/handlers/simulator.ts | 38 ++- src/tools/helpers.ts | 8 +- 16 files changed, 934 insertions(+), 13 deletions(-) create mode 100644 .agents/skills/swift-agent-debug-log/SKILL.md create mode 100644 skills/swift-agent-debug-log/SKILL.md create mode 100644 src/core/agent-debug-log.test.ts create mode 100644 src/core/agent-debug-log.ts create mode 100644 src/tools/handlers/agent-debug.ts diff --git a/.agents/skills/simulator-workflow/SKILL.md b/.agents/skills/simulator-workflow/SKILL.md index 4b8f880..07e6714 100644 --- a/.agents/skills/simulator-workflow/SKILL.md +++ b/.agents/skills/simulator-workflow/SKILL.md @@ -37,6 +37,12 @@ The `bazel_ios_build_and_run` tool chains these steps (each failure short-circui Env vars are passed via `SIMCTL_CHILD_*` prefix convention — simctl forwards them to the launched process. +For **Cursor DEBUG MODE** (NDJSON hypothesis logs), use the `swift-agent-debug-log` skill and `bazel_ios_agent_debug_*` tools: + +- `AGENT_DEBUG_LOG_PATH` / `AGENT_DEBUG_SESSION_ID` via `launchEnv` on `build_and_run` or `agent_debug_repro` +- Simulator fallback: `Documents/agent-debug.ndjson` + `bazel_ios_agent_debug_log_pull` +- Do not rely on the sim app writing directly to `.cursor/debug-*.log` on the host + ## Platform CPU Flags | Platform | Flag | diff --git a/.agents/skills/swift-agent-debug-log/SKILL.md b/.agents/skills/swift-agent-debug-log/SKILL.md new file mode 100644 index 0000000..760b246 --- /dev/null +++ b/.agents/skills/swift-agent-debug-log/SKILL.md @@ -0,0 +1,109 @@ +--- +name: swift-agent-debug-log +description: >- + Instrument Swift/iOS apps for Cursor DEBUG MODE: NDJSON hypothesis logs on the + host or simulator Documents. Use with XcodeBazelMCP agent_debug tools (clear, + read, pull, repro) instead of Read/delete_file on host paths from sim code. +--- + +# Swift Agent Debug Log (Cursor DEBUG MODE) + +XcodeBazelMCP owns **runtime** (build, launch, read logs). This skill is the **protocol** (NDJSON schema, `hypothesisId`, session cleanup). + +## Log path strategy + +| Runtime | Where Swift writes | How the agent reads | +|---------|-------------------|---------------------| +| macOS / unit tests | Host path from env | `bazel_ios_agent_debug_log_read` | +| iOS Simulator | `Documents/agent-debug.ndjson` (sandbox-safe) | `bazel_ios_agent_debug_log_pull` or env if using host mount | + +**Never** hardcode repo paths like `Apps/Consumer/.cursor/debug-*.log` in app code — the sim sandbox cannot write there. + +### Environment (preferred for host / tests) + +`bazel_ios_build_and_run` / `bazel_ios_agent_debug_repro` pass via `launchEnv` → `SIMCTL_CHILD_*`: + +- `AGENT_DEBUG_LOG_PATH` — absolute host path (e.g. `/.cursor/debug-{session}.log`) +- `AGENT_DEBUG_SESSION_ID` — Cursor debug session id + +Swift reads `ProcessInfo.processInfo.environment` and appends one NDJSON object per line. + +### Simulator fallback + +If no host path is writable, append NDJSON to: + +`FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]/agent-debug.ndjson` + +Then pull with `bazel_ios_agent_debug_log_pull` (`destPath` optional copy to `.cursor/debug-*.log`). + +## Swift instrumentation (minimal) + +```swift +func agentDebugLog( + location: String, + message: String, + data: [String: Any] = [:], + hypothesisId: String, + runId: String = "pre-fix" +) { + let env = ProcessInfo.processInfo.environment + let sessionId = env["AGENT_DEBUG_SESSION_ID"] ?? "" + let payload: [String: Any] = [ + "sessionId": sessionId, + "location": location, + "message": message, + "data": data, + "hypothesisId": hypothesisId, + "runId": runId, + "timestamp": Int(Date().timeIntervalSince1970 * 1000), + ] + guard let line = try? JSONSerialization.data(withJSONObject: payload), + let str = String(data: line, encoding: .utf8) else { return } + let row = str + "\n" + if let path = env["AGENT_DEBUG_LOG_PATH"], !path.isEmpty { + if let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile(); handle.write(Data(row.utf8)); try? handle.close() + } else { + FileManager.default.createFile(atPath: path, contents: Data(row.utf8)) + } + return + } + let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let url = doc.appendingPathComponent("agent-debug.ndjson") + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile(); handle.write(Data(row.utf8)); try? handle.close() + } else { + try? row.write(to: url, atomically: true, encoding: .utf8) + } +} +``` + +Wrap calls in `// #region agent log` … `// #endregion` so Xcode folds them. + +## Agent workflow (use MCP tools, not raw file tools) + +1. `bazel_ios_agent_debug_log_clear` — `{ "logPath": "/.cursor/debug-{session}.log" }` +2. `bazel_ios_agent_debug_repro` or `bazel_ios_build_and_run` with `launchEnv` / repro sets `AGENT_DEBUG_*` +3. User reproduces the bug +4. `bazel_ios_agent_debug_log_read` — filter `hypothesisId`, `runId`; use `hypothesisStatusHints` for CONFIRMED/REJECTED hints +5. If empty on host → `bazel_ios_agent_debug_log_pull` with `bundleId` + optional `destPath` + +MCP resource: `xcodebazel://agent-debug-log?path=` + +## NDJSON line schema + +```json +{ + "sessionId": "522bed", + "location": "MyType.swift:42", + "message": "branch taken", + "data": { "count": 3 }, + "hypothesisId": "A", + "runId": "pre-fix", + "timestamp": 1733456789000 +} +``` + +## Optional: os_log transport + +`bazel_ios_log_capture_start` with `jsonLinesOnly: true` or `messageContains: "agentDebugLog"` — noisier than file pull; use when you cannot add file IO. diff --git a/skills/swift-agent-debug-log/SKILL.md b/skills/swift-agent-debug-log/SKILL.md new file mode 100644 index 0000000..760b246 --- /dev/null +++ b/skills/swift-agent-debug-log/SKILL.md @@ -0,0 +1,109 @@ +--- +name: swift-agent-debug-log +description: >- + Instrument Swift/iOS apps for Cursor DEBUG MODE: NDJSON hypothesis logs on the + host or simulator Documents. Use with XcodeBazelMCP agent_debug tools (clear, + read, pull, repro) instead of Read/delete_file on host paths from sim code. +--- + +# Swift Agent Debug Log (Cursor DEBUG MODE) + +XcodeBazelMCP owns **runtime** (build, launch, read logs). This skill is the **protocol** (NDJSON schema, `hypothesisId`, session cleanup). + +## Log path strategy + +| Runtime | Where Swift writes | How the agent reads | +|---------|-------------------|---------------------| +| macOS / unit tests | Host path from env | `bazel_ios_agent_debug_log_read` | +| iOS Simulator | `Documents/agent-debug.ndjson` (sandbox-safe) | `bazel_ios_agent_debug_log_pull` or env if using host mount | + +**Never** hardcode repo paths like `Apps/Consumer/.cursor/debug-*.log` in app code — the sim sandbox cannot write there. + +### Environment (preferred for host / tests) + +`bazel_ios_build_and_run` / `bazel_ios_agent_debug_repro` pass via `launchEnv` → `SIMCTL_CHILD_*`: + +- `AGENT_DEBUG_LOG_PATH` — absolute host path (e.g. `/.cursor/debug-{session}.log`) +- `AGENT_DEBUG_SESSION_ID` — Cursor debug session id + +Swift reads `ProcessInfo.processInfo.environment` and appends one NDJSON object per line. + +### Simulator fallback + +If no host path is writable, append NDJSON to: + +`FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]/agent-debug.ndjson` + +Then pull with `bazel_ios_agent_debug_log_pull` (`destPath` optional copy to `.cursor/debug-*.log`). + +## Swift instrumentation (minimal) + +```swift +func agentDebugLog( + location: String, + message: String, + data: [String: Any] = [:], + hypothesisId: String, + runId: String = "pre-fix" +) { + let env = ProcessInfo.processInfo.environment + let sessionId = env["AGENT_DEBUG_SESSION_ID"] ?? "" + let payload: [String: Any] = [ + "sessionId": sessionId, + "location": location, + "message": message, + "data": data, + "hypothesisId": hypothesisId, + "runId": runId, + "timestamp": Int(Date().timeIntervalSince1970 * 1000), + ] + guard let line = try? JSONSerialization.data(withJSONObject: payload), + let str = String(data: line, encoding: .utf8) else { return } + let row = str + "\n" + if let path = env["AGENT_DEBUG_LOG_PATH"], !path.isEmpty { + if let handle = FileHandle(forWritingAtPath: path) { + handle.seekToEndOfFile(); handle.write(Data(row.utf8)); try? handle.close() + } else { + FileManager.default.createFile(atPath: path, contents: Data(row.utf8)) + } + return + } + let doc = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let url = doc.appendingPathComponent("agent-debug.ndjson") + if let handle = try? FileHandle(forWritingTo: url) { + handle.seekToEndOfFile(); handle.write(Data(row.utf8)); try? handle.close() + } else { + try? row.write(to: url, atomically: true, encoding: .utf8) + } +} +``` + +Wrap calls in `// #region agent log` … `// #endregion` so Xcode folds them. + +## Agent workflow (use MCP tools, not raw file tools) + +1. `bazel_ios_agent_debug_log_clear` — `{ "logPath": "/.cursor/debug-{session}.log" }` +2. `bazel_ios_agent_debug_repro` or `bazel_ios_build_and_run` with `launchEnv` / repro sets `AGENT_DEBUG_*` +3. User reproduces the bug +4. `bazel_ios_agent_debug_log_read` — filter `hypothesisId`, `runId`; use `hypothesisStatusHints` for CONFIRMED/REJECTED hints +5. If empty on host → `bazel_ios_agent_debug_log_pull` with `bundleId` + optional `destPath` + +MCP resource: `xcodebazel://agent-debug-log?path=` + +## NDJSON line schema + +```json +{ + "sessionId": "522bed", + "location": "MyType.swift:42", + "message": "branch taken", + "data": { "count": 3 }, + "hypothesisId": "A", + "runId": "pre-fix", + "timestamp": 1733456789000 +} +``` + +## Optional: os_log transport + +`bazel_ios_log_capture_start` with `jsonLinesOnly: true` or `messageContains: "agentDebugLog"` — noisier than file pull; use when you cannot add file IO. diff --git a/src/cli.test.ts b/src/cli.test.ts index 0903aff..06de219 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -41,12 +41,12 @@ describe('CLI help', () => { }); describe('CLI tools', () => { - it('lists all 112 tools', () => { + it('lists all 116 tools', () => { const out = run(['tools']); const toolLines = out .split('\n') .filter((line) => line.match(/^[a-z_]+$/)); - expect(toolLines.length).toBe(112); + expect(toolLines.length).toBe(116); expect(out).toContain('bazel_ios_build'); expect(out).toContain('bazel_macos_build'); expect(out).toContain('bazel_tvos_build'); diff --git a/src/cli/commands.ts b/src/cli/commands.ts index c36e6b7..ce72db3 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,7 +1,9 @@ import { createInterface } from 'node:readline'; -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, cpSync } from 'node:fs'; import { spawn } from 'node:child_process'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; import { callBazelTool, callBazelToolStreaming } from '../tools/index.js'; import type { JsonObject } from '../types/index.js'; @@ -27,6 +29,54 @@ export async function printTool(name: string, args: JsonObject): Promise { if (result.isError) process.exitCode = 1; } +function bundledSkillsDir(): string | null { + const here = dirname(fileURLToPath(import.meta.url)); + for (const candidate of [join(here, '..', 'skills'), join(here, '..', '..', 'skills')]) { + if (existsSync(candidate)) return candidate; + } + const cwdSkill = join(process.cwd(), 'skills'); + if (existsSync(cwdSkill)) return cwdSkill; + return null; +} + +function installBundledSkills(): void { + const source = bundledSkillsDir(); + if (!source) { + console.log('No bundled skills directory found (skip skill copy).'); + return; + } + + const skillNames = readdirSync(source, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + + const destRoots = [ + join(homedir(), '.cursor', 'skills'), + join(process.cwd(), '.agents', 'skills'), + ]; + + for (const skillName of skillNames) { + const srcSkill = join(source, skillName, 'SKILL.md'); + if (!existsSync(srcSkill)) continue; + const content = readFileSync(srcSkill, 'utf-8'); + + for (const root of destRoots) { + const destDir = join(root, skillName); + mkdirSync(destDir, { recursive: true }); + const destFile = join(destDir, 'SKILL.md'); + writeFileSync(destFile, content); + console.log(`Installed skill: ${destFile}`); + } + + const agentsMirror = join(process.cwd(), '.agents', 'skills', skillName); + if (!existsSync(agentsMirror)) { + mkdirSync(dirname(agentsMirror), { recursive: true }); + cpSync(join(source, skillName), agentsMirror, { recursive: true }); + console.log(`Installed skill: ${agentsMirror}`); + } + } +} + export function runSkillInit(): void { const skillContent = `# XcodeBazelMCP Skill @@ -46,6 +96,16 @@ Key commands: - \`bazel_ios_set_defaults\` — Set default target, simulator, build mode - \`bazel_ios_clean\` — Clean build outputs - \`bazel_ios_log_capture_start\` / \`bazel_ios_log_capture_stop\` — Capture simulator logs +- \`bazel_ios_agent_debug_log_clear\` / \`read\` / \`pull\` / \`repro\` — Cursor DEBUG MODE NDJSON workflow + +## Cursor debug mode (swift-agent-debug-log) + +1. \`bazel_ios_agent_debug_log_clear\` with \`logPath\` → \`.cursor/debug-{session}.log\` +2. \`bazel_ios_agent_debug_repro\` (or \`build_and_run\` with \`launchEnv\`: \`AGENT_DEBUG_LOG_PATH\`, \`AGENT_DEBUG_SESSION_ID\`) +3. User reproduces the bug in the simulator +4. \`bazel_ios_agent_debug_log_read\` on the host log, or \`bazel_ios_agent_debug_log_pull\` if Swift wrote to \`Documents/agent-debug.ndjson\` + +See skill \`swift-agent-debug-log\` (installed by \`xcodebazelmcp init\`). All build/test/query tools support \`streaming: true\` for real-time output via MCP progress notifications. @@ -83,6 +143,8 @@ xcodebazelmcp coverage //tests:tests writeFileSync(fallback, skillContent); console.log(`Installed: ${fallback}`); } + + installBundledSkills(); } export async function runUpgrade(args: string[]): Promise { diff --git a/src/core/agent-debug-log.test.ts b/src/core/agent-debug-log.test.ts new file mode 100644 index 0000000..842de94 --- /dev/null +++ b/src/core/agent-debug-log.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + agentDebugLaunchEnv, + clearAgentDebugLog, + parseAgentDebugLogUri, + readAgentDebugLog, + parseAgentDebugNdjson, + extractNdjsonFromLogCapture, +} from './agent-debug-log.js'; + +describe('agent-debug-log', () => { + let tempDir: string; + let logPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'xbmcp-agent-debug-')); + logPath = join(tempDir, 'debug.log'); + }); + + afterEach(() => { + if (existsSync(logPath)) unlinkSync(logPath); + }); + + it('agentDebugLaunchEnv sets expected keys', () => { + expect(agentDebugLaunchEnv('/tmp/debug.log', 'abc123')).toEqual({ + AGENT_DEBUG_LOG_PATH: '/tmp/debug.log', + AGENT_DEBUG_SESSION_ID: 'abc123', + }); + }); + + it('clearAgentDebugLog removes existing file', () => { + writeFileSync(logPath, '{"message":"x"}\n'); + const result = clearAgentDebugLog(logPath); + expect(result.existed).toBe(true); + expect(result.cleared).toBe(true); + expect(existsSync(logPath)).toBe(false); + }); + + it('readAgentDebugLog parses NDJSON and groups by hypothesis', () => { + writeFileSync( + logPath, + [ + '{"hypothesisId":"A","runId":"r1","message":"CONFIRMED fix"}', + '{"hypothesisId":"B","runId":"r1","message":"REJECTED bad path"}', + 'not-json', + ].join('\n'), + ); + + const result = readAgentDebugLog({ logPath }); + expect(result.exists).toBe(true); + expect(result.entries).toHaveLength(2); + expect(result.parseErrors).toHaveLength(1); + expect(result.byHypothesisId.A).toHaveLength(1); + expect(result.hypothesisStatusHints.A).toBe('CONFIRMED'); + expect(result.hypothesisStatusHints.B).toBe('REJECTED'); + }); + + it('readAgentDebugLog filters by hypothesisId and runId', () => { + writeFileSync( + logPath, + '{"hypothesisId":"A","runId":"r1"}\n{"hypothesisId":"A","runId":"r2"}\n', + ); + const filtered = readAgentDebugLog({ logPath, hypothesisId: 'A', runId: 'r2' }); + expect(filtered.entries).toHaveLength(1); + expect(filtered.entries[0].runId).toBe('r2'); + }); + + it('parseAgentDebugLogUri extracts path query', () => { + expect(parseAgentDebugLogUri('xcodebazel://agent-debug-log?path=%2Ftmp%2Fx.log')).toBe('/tmp/x.log'); + expect(parseAgentDebugLogUri('xcodebazel://last-command')).toBeNull(); + }); + + it('parseAgentDebugNdjson handles empty lines', () => { + const { entries, parseErrors } = parseAgentDebugNdjson('\n\n{"a":1}\n\n'); + expect(entries).toHaveLength(1); + expect(parseErrors).toHaveLength(0); + }); + + it('extractNdjsonFromLogCapture filters json lines with hypothesisId', () => { + const text = '{"hypothesisId":"H1"}\nplain text\n{"foo":1}\n'; + const filtered = extractNdjsonFromLogCapture(text, { jsonLinesOnly: true }); + expect(filtered).toHaveLength(1); + expect(filtered[0].hypothesisId).toBe('H1'); + }); + + it('read missing log returns exists false', () => { + const result = readAgentDebugLog({ logPath: join(tempDir, 'missing.log') }); + expect(result.exists).toBe(false); + expect(result.entries).toEqual([]); + }); +}); diff --git a/src/core/agent-debug-log.ts b/src/core/agent-debug-log.ts new file mode 100644 index 0000000..cb656cd --- /dev/null +++ b/src/core/agent-debug-log.ts @@ -0,0 +1,234 @@ +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, copyFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { runCommand } from '../utils/process.js'; + +export const AGENT_DEBUG_ENV_LOG_PATH = 'AGENT_DEBUG_LOG_PATH'; +export const AGENT_DEBUG_ENV_SESSION_ID = 'AGENT_DEBUG_SESSION_ID'; +export const AGENT_DEBUG_SIM_REL_PATH = 'Documents/agent-debug.ndjson'; + +export interface AgentDebugLogEntry { + sessionId?: string; + id?: string; + timestamp?: number; + location?: string; + message?: string; + data?: Record; + runId?: string; + hypothesisId?: string; + [key: string]: unknown; +} + +export interface AgentDebugParseError { + line: number; + raw: string; + error: string; +} + +export interface AgentDebugReadOptions { + logPath: string; + hypothesisId?: string; + runId?: string; + limit?: number; +} + +export interface AgentDebugReadResult { + logPath: string; + exists: boolean; + lineCount: number; + entries: AgentDebugLogEntry[]; + byHypothesisId: Record; + byRunId: Record; + hypothesisStatusHints: Record; + parseErrors: AgentDebugParseError[]; +} + +export function agentDebugLaunchEnv(logPath: string, sessionId: string): Record { + return { + [AGENT_DEBUG_ENV_LOG_PATH]: logPath, + [AGENT_DEBUG_ENV_SESSION_ID]: sessionId, + }; +} + +export function clearAgentDebugLog(logPath: string): { logPath: string; cleared: boolean; existed: boolean } { + const existed = existsSync(logPath); + if (existed) { + unlinkSync(logPath); + } + return { logPath, cleared: true, existed }; +} + +export function parseAgentDebugNdjson(content: string): { + entries: AgentDebugLogEntry[]; + parseErrors: AgentDebugParseError[]; +} { + const entries: AgentDebugLogEntry[] = []; + const parseErrors: AgentDebugParseError[] = []; + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const raw = lines[i].trim(); + if (!raw) continue; + try { + const parsed = JSON.parse(raw) as AgentDebugLogEntry; + entries.push(parsed); + } catch (err) { + parseErrors.push({ line: i + 1, raw, error: (err as Error).message }); + } + } + + return { entries, parseErrors }; +} + +function inferHypothesisStatus(entries: AgentDebugLogEntry[]): 'CONFIRMED' | 'REJECTED' | 'INCONCLUSIVE' | 'UNKNOWN' { + for (const entry of entries) { + const blob = `${entry.message ?? ''} ${JSON.stringify(entry.data ?? {})}`.toUpperCase(); + if (blob.includes('CONFIRMED')) return 'CONFIRMED'; + if (blob.includes('REJECTED')) return 'REJECTED'; + if (blob.includes('INCONCLUSIVE')) return 'INCONCLUSIVE'; + } + return 'UNKNOWN'; +} + +export function readAgentDebugLog(options: AgentDebugReadOptions): AgentDebugReadResult { + const { logPath, hypothesisId, runId, limit } = options; + if (!existsSync(logPath)) { + return { + logPath, + exists: false, + lineCount: 0, + entries: [], + byHypothesisId: {}, + byRunId: {}, + hypothesisStatusHints: {}, + parseErrors: [], + }; + } + + const content = readFileSync(logPath, 'utf-8'); + const { entries: allEntries, parseErrors } = parseAgentDebugNdjson(content); + + let entries = allEntries; + if (hypothesisId) entries = entries.filter((e) => e.hypothesisId === hypothesisId); + if (runId) entries = entries.filter((e) => e.runId === runId); + if (typeof limit === 'number' && limit > 0) entries = entries.slice(-limit); + + const byHypothesisId: Record = {}; + const byRunId: Record = {}; + for (const entry of allEntries) { + if (entry.hypothesisId) { + (byHypothesisId[entry.hypothesisId] ??= []).push(entry); + } + if (entry.runId) { + (byRunId[entry.runId] ??= []).push(entry); + } + } + + const hypothesisStatusHints: Record = {}; + for (const [hid, group] of Object.entries(byHypothesisId)) { + hypothesisStatusHints[hid] = inferHypothesisStatus(group); + } + + const lineCount = content.split('\n').filter((l) => l.trim()).length; + + return { + logPath, + exists: true, + lineCount, + entries, + byHypothesisId, + byRunId, + hypothesisStatusHints, + parseErrors, + }; +} + +export function parseAgentDebugLogUri(uri: string): string | null { + try { + const parsed = new URL(uri); + if (parsed.protocol !== 'xcodebazel:' || parsed.hostname !== 'agent-debug-log') return null; + const path = parsed.searchParams.get('path'); + return path || null; + } catch { + return null; + } +} + +export interface PullAgentDebugLogOptions { + bundleId: string; + simulatorId: string; + destPath?: string; + simRelPath?: string; +} + +export async function pullAgentDebugLogFromSimulator( + options: PullAgentDebugLogOptions, +): Promise<{ + bundleId: string; + simulatorId: string; + containerPath: string; + sourcePath: string; + destPath?: string; + read: AgentDebugReadResult; + commandOutput: string; +}> { + const simRelPath = options.simRelPath ?? AGENT_DEBUG_SIM_REL_PATH; + const result = await runCommand( + 'xcrun', + ['simctl', 'get_app_container', options.simulatorId, options.bundleId, 'data'], + { cwd: process.cwd(), timeoutSeconds: 30, maxOutput: 10_000 }, + ); + + if (result.exitCode !== 0) { + throw new Error( + `simctl get_app_container failed (exit ${result.exitCode}): ${result.output.trim() || 'no output'}`, + ); + } + + const containerPath = result.output.trim(); + const sourcePath = join(containerPath, simRelPath); + + let read: AgentDebugReadResult; + let destPath: string | undefined; + + if (options.destPath) { + destPath = options.destPath; + const parent = dirname(destPath); + if (!existsSync(parent)) mkdirSync(parent, { recursive: true }); + if (existsSync(sourcePath)) { + copyFileSync(sourcePath, destPath); + } else { + writeFileSync(destPath, ''); + } + read = readAgentDebugLog({ logPath: destPath }); + } else { + read = readAgentDebugLog({ logPath: sourcePath }); + } + + return { + bundleId: options.bundleId, + simulatorId: options.simulatorId, + containerPath, + sourcePath, + destPath, + read, + commandOutput: result.output.trim(), + }; +} + +export function extractNdjsonFromLogCapture( + output: string, + options?: { messageContains?: string; jsonLinesOnly?: boolean }, +): AgentDebugLogEntry[] { + const { entries } = parseAgentDebugNdjson(output); + if (!options?.messageContains && !options?.jsonLinesOnly) return entries; + + const needle = options.messageContains?.toLowerCase(); + return entries.filter((entry) => { + if (needle) { + const hay = `${entry.message ?? ''} ${JSON.stringify(entry.data ?? {})}`.toLowerCase(); + if (!hay.includes(needle)) return false; + } + if (options.jsonLinesOnly && !entry.hypothesisId && !entry.sessionId) return false; + return true; + }); +} diff --git a/src/core/workflows.ts b/src/core/workflows.ts index fd58c57..66c4508 100644 --- a/src/core/workflows.ts +++ b/src/core/workflows.ts @@ -49,6 +49,17 @@ export const WORKFLOWS: WorkflowInfo[] = [ 'bazel_ios_log_capture_stop', ], }, + { + id: 'agent_debug', + name: 'Agent Debug (Cursor)', + description: 'NDJSON agent debug logs: clear, read, pull from simulator, one-shot repro.', + tools: [ + 'bazel_ios_agent_debug_log_clear', + 'bazel_ios_agent_debug_log_read', + 'bazel_ios_agent_debug_log_pull', + 'bazel_ios_agent_debug_repro', + ], + }, { id: 'ui_automation', name: 'UI Automation', @@ -184,7 +195,7 @@ export const WORKFLOWS: WorkflowInfo[] = [ const ALL_WORKFLOW_IDS = new Set(WORKFLOWS.map((w) => w.id)); export const DEFAULT_WORKFLOWS = [ - 'build', 'test', 'simulator', 'app_lifecycle', 'project', 'session', + 'build', 'test', 'simulator', 'app_lifecycle', 'project', 'session', 'agent_debug', ]; export function validateWorkflowIds(ids: string[]): string[] { diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index ba3b1b1..85a3c81 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -77,7 +77,7 @@ describe('MCP server protocol', () => { const toolsResp = lines.map((l) => JSON.parse(l)).find((r) => r.id === 2); expect(toolsResp).toBeDefined(); - expect(toolsResp.result.tools).toHaveLength(34); + expect(toolsResp.result.tools).toHaveLength(38); const names = toolsResp.result.tools.map((t: { name: string }) => t.name); expect(names).toContain('bazel_ios_build'); expect(names).toContain('bazel_ios_test'); @@ -109,10 +109,11 @@ describe('MCP server protocol', () => { ]); const resp = lines.map((l) => JSON.parse(l)).find((r) => r.id === 2); - expect(resp.result.resources).toHaveLength(2); + expect(resp.result.resources).toHaveLength(3); const uris = resp.result.resources.map((r: { uri: string }) => r.uri); expect(uris).toContain('xcodebazel://last-command'); expect(uris).toContain('xcodebazel://session-status'); + expect(uris).toContain('xcodebazel://agent-debug-log'); }); it('reads last-command resource (no command yet)', async () => { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 57d001d..2e46405 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -89,6 +89,12 @@ async function handleMessage(message: JsonRpcMessage): Promise { description: 'Current session state: active workflows, defaults, uptime.', mimeType: 'application/json', }, + { + uri: 'xcodebazel://agent-debug-log', + name: 'Agent debug NDJSON log', + description: 'Structured agent debug log. Read with ?path= query on the URI.', + mimeType: 'application/json', + }, ], }); } @@ -137,6 +143,23 @@ async function handleMessage(message: JsonRpcMessage): Promise { ], }); } + if (params?.uri?.startsWith('xcodebazel://agent-debug-log')) { + const { parseAgentDebugLogUri, readAgentDebugLog } = await import('../core/agent-debug-log.js'); + const logPath = parseAgentDebugLogUri(params.uri); + if (!logPath) { + throw new Error('agent-debug-log resource requires ?path= query parameter.'); + } + const result = readAgentDebugLog({ logPath }); + return sendResult(id, { + contents: [ + { + uri: params.uri, + mimeType: 'application/json', + text: JSON.stringify(result, null, 2), + }, + ], + }); + } throw new Error(`Unknown resource: ${params?.uri}`); } if (method === 'tools/call') { diff --git a/src/tools/bazel-tools.test.ts b/src/tools/bazel-tools.test.ts index 858d025..9d15a8e 100644 --- a/src/tools/bazel-tools.test.ts +++ b/src/tools/bazel-tools.test.ts @@ -33,6 +33,10 @@ describe('Bazel MCP tool definitions', () => { 'bazel_ios_test_coverage', 'bazel_ios_log_capture_start', 'bazel_ios_log_capture_stop', + 'bazel_ios_agent_debug_log_clear', + 'bazel_ios_agent_debug_log_read', + 'bazel_ios_agent_debug_log_pull', + 'bazel_ios_agent_debug_repro', 'bazel_ios_last_command', 'bazel_ios_bsp_status', 'bazel_ios_stop_app', @@ -122,7 +126,7 @@ describe('Bazel MCP tool definitions', () => { ]; expect([...names].sort()).toEqual([...expected].sort()); expect(new Set(names).size).toBe(names.length); - expect(bazelToolDefinitions.length).toBe(112); + expect(bazelToolDefinitions.length).toBe(116); }); it('advertises startupArgs on every Bazel command tool that can need startup flags', () => { @@ -458,6 +462,26 @@ describe('Set defaults tool', () => { expect(text).toContain('No profiles configured'); }); + it('bazel_ios_agent_debug_log_clear and read round-trip', async () => { + const { mkdtempSync, existsSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + const dir = mkdtempSync(join(tmpdir(), 'xbmcp-adbg-')); + const logPath = join(dir, 'debug.log'); + + const clearResult = await callBazelTool('bazel_ios_agent_debug_log_clear', { logPath }); + expect(clearResult.isError).toBeFalsy(); + expect(existsSync(logPath)).toBe(false); + + const { writeFileSync } = await import('node:fs'); + writeFileSync(logPath, '{"hypothesisId":"H1","message":"ok"}\n'); + + const readResult = await callBazelTool('bazel_ios_agent_debug_log_read', { logPath, hypothesisId: 'H1' }); + const parsed = JSON.parse(extractText(readResult)); + expect(parsed.entries).toHaveLength(1); + expect(parsed.hypothesisStatusHints.H1).toBe('UNKNOWN'); + }); + it('bazel_ios_last_command returns no command initially', async () => { const result = await callBazelTool('bazel_ios_last_command', {}); const text = extractText(result); diff --git a/src/tools/bazel-tools.ts b/src/tools/bazel-tools.ts index 56f3c6f..5b702bb 100644 --- a/src/tools/bazel-tools.ts +++ b/src/tools/bazel-tools.ts @@ -11,10 +11,11 @@ import * as multiPlatform from './handlers/multi-platform.js'; import * as spm from './handlers/spm.js'; import * as scaffold from './handlers/scaffold.js'; import * as uiAutomation from './handlers/ui-automation.js'; +import * as agentDebug from './handlers/agent-debug.js'; const handlerModules = [ session, build, simulator, device, lldb, - macos, multiPlatform, spm, scaffold, uiAutomation, + macos, multiPlatform, spm, scaffold, uiAutomation, agentDebug, ]; export const bazelToolDefinitions: ToolDefinition[] = handlerModules.flatMap(m => m.definitions); diff --git a/src/tools/handlers/agent-debug.ts b/src/tools/handlers/agent-debug.ts new file mode 100644 index 0000000..7d51a2d --- /dev/null +++ b/src/tools/handlers/agent-debug.ts @@ -0,0 +1,203 @@ +import type { JsonObject, ToolCallResult, ToolDefinition } from '../../types/index.js'; +import { + agentDebugLaunchEnv, + clearAgentDebugLog, + readAgentDebugLog, + pullAgentDebugLogFromSimulator, + AGENT_DEBUG_SIM_REL_PATH, +} from '../../core/agent-debug-log.js'; +import { resolveSimulatorFromArgs, stringOrUndefined, prependWarning } from '../helpers.js'; +import { toolText } from '../../utils/output.js'; + +export const definitions: ToolDefinition[] = [ + { + name: 'bazel_ios_agent_debug_log_clear', + description: + 'Delete an agent debug NDJSON log file before a repro run (Cursor debug mode). Safe no-op if missing.', + inputSchema: { + type: 'object', + properties: { + logPath: { + type: 'string', + description: 'Absolute path to .cursor/debug-{session}.log on the host.', + }, + }, + required: ['logPath'], + }, + }, + { + name: 'bazel_ios_agent_debug_log_read', + description: + 'Read and parse an agent debug NDJSON log. Returns structured entries grouped by hypothesisId/runId with status hints.', + inputSchema: { + type: 'object', + properties: { + logPath: { type: 'string', description: 'Absolute path to the NDJSON log file.' }, + hypothesisId: { type: 'string', description: 'Filter entries to this hypothesisId.' }, + runId: { type: 'string', description: 'Filter entries to this runId.' }, + limit: { type: 'number', description: 'Return only the last N matching entries.' }, + }, + required: ['logPath'], + }, + }, + { + name: 'bazel_ios_agent_debug_log_pull', + description: + 'Pull agent-debug.ndjson from a simulator app data container (Documents/agent-debug.ndjson) via simctl get_app_container.', + inputSchema: { + type: 'object', + properties: { + bundleId: { type: 'string', description: 'App bundle identifier.' }, + simulatorId: { type: 'string', description: 'Simulator UDID (default: first booted).' }, + simulatorName: { type: 'string', description: 'Simulator name (alternative to simulatorId).' }, + destPath: { + type: 'string', + description: + 'Optional host path to copy the file (e.g. .cursor/debug-{session}.log). Parses NDJSON from dest after copy.', + }, + simRelPath: { + type: 'string', + description: `Relative path inside app data container (default: ${AGENT_DEBUG_SIM_REL_PATH}).`, + }, + }, + required: ['bundleId'], + }, + }, + { + name: 'bazel_ios_agent_debug_repro', + description: + 'One-shot Cursor debug repro: clear host log → build_and_run with AGENT_DEBUG_* launchEnv → optional log capture.', + inputSchema: { + type: 'object', + properties: { + target: { type: 'string', description: 'Bazel iOS app target label.' }, + logPath: { type: 'string', description: 'Host NDJSON log path (.cursor/debug-{session}.log).' }, + sessionId: { type: 'string', description: 'Debug session id (passed as AGENT_DEBUG_SESSION_ID).' }, + hypothesisIds: { + type: 'array', + items: { type: 'string' }, + description: 'Optional hypothesis ids for agent context (echoed in response only).', + }, + launchEnv: { + type: 'object', + additionalProperties: { type: 'string' }, + description: 'Extra env vars merged with AGENT_DEBUG_LOG_PATH and AGENT_DEBUG_SESSION_ID.', + }, + startLogCapture: { + type: 'boolean', + description: 'Start bazel_ios_log_capture_start after launch (returns captureId).', + }, + simulatorId: { type: 'string' }, + simulatorName: { type: 'string' }, + buildMode: { type: 'string', enum: ['none', 'debug', 'release', 'release_with_symbols'] }, + configs: { type: 'array', items: { type: 'string' } }, + launchArgs: { type: 'array', items: { type: 'string' } }, + }, + required: ['target', 'logPath', 'sessionId'], + }, + }, +]; + +const HANDLED = new Set(definitions.map((d) => d.name)); + +export function canHandle(name: string): boolean { + return HANDLED.has(name); +} + +export async function handle(name: string, args: JsonObject): Promise { + switch (name) { + case 'bazel_ios_agent_debug_log_clear': { + if (typeof args.logPath !== 'string') throw new Error('logPath is required.'); + const result = clearAgentDebugLog(args.logPath); + return toolText(JSON.stringify(result, null, 2)); + } + case 'bazel_ios_agent_debug_log_read': { + if (typeof args.logPath !== 'string') throw new Error('logPath is required.'); + const result = readAgentDebugLog({ + logPath: args.logPath, + hypothesisId: stringOrUndefined(args.hypothesisId), + runId: stringOrUndefined(args.runId), + limit: typeof args.limit === 'number' ? args.limit : undefined, + }); + return toolText(JSON.stringify(result, null, 2)); + } + case 'bazel_ios_agent_debug_log_pull': { + if (typeof args.bundleId !== 'string') throw new Error('bundleId is required.'); + const { sim, warning } = await resolveSimulatorFromArgs(args); + const pulled = await pullAgentDebugLogFromSimulator({ + bundleId: args.bundleId, + simulatorId: sim.udid, + destPath: stringOrUndefined(args.destPath), + simRelPath: stringOrUndefined(args.simRelPath), + }); + return toolText( + prependWarning(JSON.stringify(pulled, null, 2), warning), + !pulled.read.exists && pulled.read.lineCount === 0, + ); + } + case 'bazel_ios_agent_debug_repro': { + if (typeof args.target !== 'string') throw new Error('target is required.'); + if (typeof args.logPath !== 'string') throw new Error('logPath is required.'); + if (typeof args.sessionId !== 'string') throw new Error('sessionId is required.'); + + const cleared = clearAgentDebugLog(args.logPath); + const debugEnv = agentDebugLaunchEnv(args.logPath, args.sessionId); + const userEnv = (args.launchEnv as Record | undefined) || {}; + const launchEnv = { ...userEnv, ...debugEnv }; + + const { callBazelTool } = await import('../bazel-tools.js'); + const buildRunArgs: JsonObject = { + target: args.target, + launchEnv, + }; + if (args.simulatorId !== undefined) buildRunArgs.simulatorId = args.simulatorId; + if (args.simulatorName !== undefined) buildRunArgs.simulatorName = args.simulatorName; + if (args.buildMode !== undefined) buildRunArgs.buildMode = args.buildMode; + if (args.configs !== undefined) buildRunArgs.configs = args.configs; + if (args.launchArgs !== undefined) buildRunArgs.launchArgs = args.launchArgs; + + const buildResult = await callBazelTool('bazel_ios_build_and_run', buildRunArgs); + const buildText = buildResult.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + .join('\n'); + + let captureId: string | undefined; + if (args.startLogCapture === true) { + const cap = await callBazelTool('bazel_ios_log_capture_start', { + simulatorId: args.simulatorId, + simulatorName: args.simulatorName, + messageContains: 'agentDebugLog', + }); + const match = cap.content + .map((c) => (c.type === 'text' ? c.text : '')) + .join('\n') + .match(/Capture ID:\s*(\S+)/); + captureId = match?.[1]; + } + + const summary = { + logPath: args.logPath, + sessionId: args.sessionId, + hypothesisIds: Array.isArray(args.hypothesisIds) ? args.hypothesisIds : undefined, + cleared, + launchEnv, + simContainerFallback: AGENT_DEBUG_SIM_REL_PATH, + captureId, + buildAndRun: { + isError: buildResult.isError ?? false, + output: buildText, + }, + nextSteps: [ + 'Reproduce the bug in the simulator.', + 'bazel_ios_agent_debug_log_read with logPath (host path from launchEnv)', + 'Or bazel_ios_agent_debug_log_pull if Swift wrote to Documents/agent-debug.ndjson', + ], + }; + + return toolText(JSON.stringify(summary, null, 2), buildResult.isError === true); + } + default: + return undefined; + } +} diff --git a/src/tools/handlers/handlers.test.ts b/src/tools/handlers/handlers.test.ts index ac1f00c..6512c47 100644 --- a/src/tools/handlers/handlers.test.ts +++ b/src/tools/handlers/handlers.test.ts @@ -9,6 +9,7 @@ import * as multiPlatform from './multi-platform.js'; import * as spm from './spm.js'; import * as scaffold from './scaffold.js'; import * as uiAutomation from './ui-automation.js'; +import * as agentDebug from './agent-debug.js'; const handlers = [ { label: 'session', mod: session }, @@ -21,6 +22,7 @@ const handlers = [ { label: 'spm', mod: spm }, { label: 'scaffold', mod: scaffold }, { label: 'uiAutomation', mod: uiAutomation }, + { label: 'agentDebug', mod: agentDebug }, ] as const; describe('handler modules', () => { @@ -57,8 +59,8 @@ describe('handler modules', () => { } }); - it('total definitions across all handlers is 112', () => { + it('total definitions across all handlers is 116', () => { const total = handlers.reduce((sum, { mod }) => sum + mod.definitions.length, 0); - expect(total).toBe(112); + expect(total).toBe(116); }); }); diff --git a/src/tools/handlers/simulator.ts b/src/tools/handlers/simulator.ts index 27f45bc..246b8d4 100644 --- a/src/tools/handlers/simulator.ts +++ b/src/tools/handlers/simulator.ts @@ -284,6 +284,14 @@ export const definitions: ToolDefinition[] = [ processName: { type: 'string', description: 'Filter logs by process name.' }, subsystem: { type: 'string', description: 'Filter logs by os_log subsystem (e.g. com.example.MyApp).' }, level: { type: 'string', enum: ['default', 'info', 'debug'], description: 'Minimum log level.' }, + messageContains: { + type: 'string', + description: 'On stop, keep only NDJSON entries whose message/data contains this substring.', + }, + jsonLinesOnly: { + type: 'boolean', + description: 'On stop, parse NDJSON lines and return structured agent debug entries only.', + }, }, }, }, @@ -585,7 +593,13 @@ export async function handle(name: string, args: JsonObject): Promise { if (capture.output.length < maxLogSize) capture.output += chunk.toString(); @@ -606,6 +620,28 @@ export async function handle(name: string, args: JsonObject): Promise= 500_000 ? '\n[log output truncated at 500KB]' : ''; + + if (capture.jsonLinesOnly || capture.messageContains) { + const { extractNdjsonFromLogCapture } = await import('../../core/agent-debug-log.js'); + const entries = extractNdjsonFromLogCapture(logOutput, { + messageContains: capture.messageContains, + jsonLinesOnly: capture.jsonLinesOnly, + }); + return toolText( + JSON.stringify( + { + captureId: args.captureId, + simulatorId: capture.simulatorId, + entryCount: entries.length, + entries, + truncated: logOutput.length >= 500_000, + }, + null, + 2, + ) + truncated, + ); + } + return toolText(`Log capture stopped (${args.captureId}).\nSimulator: ${capture.simulatorId}\n\n${logOutput}${truncated}`); } case 'bazel_ios_push_notification': { diff --git a/src/tools/helpers.ts b/src/tools/helpers.ts index 1d08977..2c7459b 100644 --- a/src/tools/helpers.ts +++ b/src/tools/helpers.ts @@ -3,7 +3,13 @@ import { resolveSimulator, type SimulatorDevice } from '../core/simulators.js'; import { getDefaults } from '../runtime/config.js'; import type { JsonObject } from '../types/index.js'; -export const logCaptures = new Map; output: string; simulatorId: string }>(); +export const logCaptures = new Map; + output: string; + simulatorId: string; + messageContains?: string; + jsonLinesOnly?: boolean; +}>(); export let logCaptureCounter = 0; export function nextLogCaptureId(): number { return ++logCaptureCounter; }